Compare commits
457 commits
master
...
detached-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7645269e8 | ||
|
|
602bfcbad0 | ||
|
|
ce5a4d2843 | ||
|
|
a9fc4c3f66 | ||
|
|
2920265b1d | ||
|
|
b57201d0d7 | ||
|
|
2b40971bbb | ||
|
|
979628e43e | ||
|
|
826b29f6a5 | ||
|
|
9ee7671436 | ||
|
|
bd29802e1e | ||
|
|
3c9072c4da | ||
|
|
ecc8fb5443 | ||
|
|
c41cd69245 | ||
|
|
bb62530021 | ||
|
|
765940f2f3 | ||
|
|
1fca06423a | ||
|
|
d58ebab0ef | ||
|
|
f5464763a3 | ||
|
|
2cb0b2f757 | ||
|
|
79a6c9a762 | ||
|
|
75052d711f | ||
|
|
0309473038 | ||
|
|
18c25b10fe | ||
|
|
83b8c8bd74 | ||
|
|
9e42badf23 | ||
|
|
57138a3285 | ||
|
|
5ac2dbb557 | ||
|
|
66fe4904b9 | ||
|
|
083cad811d | ||
|
|
0974737822 | ||
|
|
bf6dbdf355 | ||
|
|
6f14f31c9a | ||
|
|
633f7f2627 | ||
|
|
2670c11caf | ||
|
|
a45f631e66 | ||
|
|
db75bcd8ba | ||
|
|
8b2c53e48d | ||
|
|
4d7521b3c6 | ||
|
|
5d021c5e34 | ||
|
|
769ac58b79 | ||
|
|
382b441a64 | ||
|
|
29d78b0bb7 | ||
|
|
4f24f81535 | ||
|
|
4b147d34e0 | ||
|
|
59feb51345 | ||
|
|
7d5fa5cfaa | ||
|
|
9e782ee9a3 | ||
|
|
971012757e | ||
|
|
a9de830df2 | ||
|
|
11aeaf1d68 | ||
|
|
41caea6a89 | ||
|
|
f03b0e7415 | ||
|
|
826dbf7629 | ||
|
|
9503194a69 | ||
|
|
42787f7fbb | ||
|
|
ab8e23c8cc | ||
|
|
647e4e8e5a | ||
|
|
e90a240f58 | ||
|
|
fe7501974f | ||
|
|
a025c5218b | ||
|
|
ddb3ef96af | ||
|
|
19441689c3 | ||
|
|
50b59f4762 | ||
|
|
209d61a48f | ||
|
|
1225e020ee | ||
|
|
2d59725286 | ||
|
|
4aa670aecb | ||
|
|
ec0d14c7c9 | ||
|
|
852d3b3115 | ||
|
|
8a9132062f | ||
|
|
dc1a81c791 | ||
|
|
9c53b3f8ac | ||
|
|
6ba0f00f4f | ||
|
|
03608266ce | ||
|
|
308837e87c | ||
|
|
3a955a0c7b | ||
|
|
0e3156f137 | ||
|
|
113a1be3af | ||
|
|
44abb3288c | ||
|
|
94c65549f9 | ||
|
|
d1683c22b3 | ||
|
|
1fc98faa6e | ||
|
|
cb61192960 | ||
|
|
59edaad1e4 | ||
|
|
1ea13655d7 | ||
|
|
ad7cf4c672 | ||
|
|
b3bca507a4 | ||
|
|
d45820cfe6 | ||
|
|
e152234eba | ||
|
|
a36e4b76a1 | ||
|
|
e68ba08ccc | ||
|
|
3388e80c1c | ||
|
|
db4212ca52 | ||
|
|
f108f32a3a | ||
|
|
2b16427643 | ||
|
|
8332fc4ce2 | ||
|
|
5f9f785976 | ||
|
|
14be89da62 | ||
|
|
3707412416 | ||
|
|
ada78f8c45 | ||
|
|
39d48dfe64 | ||
|
|
6f616d6fff | ||
|
|
c7309dcdf3 | ||
|
|
49784e6e29 | ||
|
|
c83ea0f5d4 | ||
|
|
17078e1721 | ||
|
|
6c793ced0f | ||
|
|
f114d4e66e | ||
|
|
4042bca83e | ||
|
|
945e6b2b6c | ||
|
|
028ae58801 | ||
|
|
f3c7c11c00 | ||
|
|
4ab50d9c7a | ||
|
|
1ba6eb1a0b | ||
|
|
8e267ae147 | ||
|
|
24654a7cac | ||
|
|
d0b358aac2 | ||
|
|
1e1e257580 | ||
|
|
f19bffed1a | ||
|
|
f2cb496b8d | ||
|
|
191806c398 | ||
|
|
b75f2e2503 | ||
|
|
2c6b4b41b5 | ||
|
|
108b3b02b6 | ||
|
|
fad7ee5288 | ||
|
|
3219dc514c | ||
|
|
17df07bf6e | ||
|
|
a0813606b7 | ||
|
|
a94dd192aa | ||
|
|
b900fb585f | ||
|
|
9d80b81bc1 | ||
|
|
41441a8fdf | ||
|
|
4da5edf24f | ||
|
|
240ecf122f | ||
|
|
c76c42b118 | ||
|
|
fefab015e3 | ||
|
|
77350dd832 | ||
|
|
59fe7d08b4 | ||
|
|
0730606201 | ||
|
|
f4fd814383 | ||
|
|
e78ace39bc | ||
|
|
26574c32d0 | ||
|
|
44d2def1e5 | ||
|
|
06eadb406d | ||
|
|
87c17c84fc | ||
|
|
cb8a265431 | ||
|
|
ced26986fd | ||
|
|
635e8576a8 | ||
|
|
ae35330f42 | ||
|
|
942f537ff6 | ||
|
|
313059f6bd | ||
|
|
fd4c3e72a7 | ||
|
|
e464a5fb06 | ||
|
|
c95e854265 | ||
|
|
1506f6c003 | ||
|
|
164dc99413 | ||
|
|
089b078a3a | ||
|
|
75215bf2bb | ||
|
|
cc444e8551 | ||
|
|
53cd1ee767 | ||
|
|
015e6d5a6a | ||
|
|
d00772bf20 | ||
|
|
53e62a4bb8 | ||
|
|
957606f8ec | ||
|
|
650cc59669 | ||
|
|
ec06347123 | ||
|
|
37cb92e928 | ||
|
|
b528038e45 | ||
|
|
e499277bc3 | ||
|
|
990acbd2ba | ||
|
|
c61213f30f | ||
|
|
49e7ed8b2c | ||
|
|
9a814457f1 | ||
|
|
ed30f72984 | ||
|
|
11e05c2dfe | ||
|
|
09d7e1b6e2 | ||
|
|
edb8f3feeb | ||
|
|
c2d9c9e579 | ||
|
|
2ab92ce6bf | ||
|
|
4bc1ac28b7 | ||
|
|
147fa07358 | ||
|
|
e4cc7fd503 | ||
|
|
b00dc19380 | ||
|
|
ea8d0cc77b | ||
|
|
2ce9b05bbd | ||
|
|
7c6b5c27b7 | ||
|
|
690b9d8415 | ||
|
|
e7eb49e992 | ||
|
|
d8cd4d8e42 | ||
|
|
330d6ad062 | ||
|
|
a02f3ad2a4 | ||
|
|
d4b11af0a9 | ||
|
|
e0dca71eb5 | ||
|
|
2095b32368 | ||
|
|
87703a8750 | ||
|
|
3253f7f6a7 | ||
|
|
9a822854f2 | ||
|
|
707b38e1b7 | ||
|
|
acac7b0a37 | ||
|
|
8c59ebbe9a | ||
|
|
8e9da582c8 | ||
|
|
00e8c2d1b4 | ||
|
|
4ee4bc69d3 | ||
|
|
1d26c4e54c | ||
|
|
2711ee3036 | ||
|
|
0539eeb252 | ||
|
|
11e2dec70b | ||
|
|
d2028b9454 | ||
|
|
1b912385ad | ||
|
|
20796156e1 | ||
|
|
38486b01f8 | ||
|
|
bb3649a266 | ||
|
|
272b542120 | ||
|
|
8debc7262a | ||
|
|
e342b270b1 | ||
|
|
b685d14d31 | ||
|
|
6908631196 | ||
|
|
9ff2d38c0d | ||
|
|
82046e8d69 | ||
|
|
a34600448b | ||
|
|
e368b368df | ||
|
|
c82b8db477 | ||
|
|
59c1829bbe | ||
|
|
f6e5510ebe | ||
|
|
ba329e3273 | ||
|
|
4ae63ca444 | ||
|
|
6ed287be24 | ||
|
|
bb03a6d605 | ||
|
|
285a651b4e | ||
|
|
df4a90e1d8 | ||
|
|
1756998afd | ||
|
|
fa64025de5 | ||
|
|
8a8be1da92 | ||
|
|
12d8632701 | ||
|
|
39c8237b6b | ||
|
|
5de4a1b8db | ||
|
|
68cce125cd | ||
|
|
bf2042801e | ||
|
|
26cffe630a | ||
|
|
fce2447ac8 | ||
|
|
9415a77f29 | ||
|
|
bcefaadaee | ||
|
|
600e32b12b | ||
|
|
f4ddfe5b0a | ||
|
|
da100ddd2e | ||
|
|
db1129d1ae | ||
|
|
f223ebc9ed | ||
|
|
678b3c99e5 | ||
|
|
5c5ca816f8 | ||
|
|
9c6e9b20cb | ||
|
|
e152be4d37 | ||
|
|
038b9dedd8 | ||
|
|
b592d15726 | ||
|
|
4925d58e23 | ||
|
|
f8954971ce | ||
|
|
48504cec1f | ||
|
|
1ddd39e47d | ||
|
|
712c5f31af | ||
|
|
3e5ec9f46c | ||
|
|
7226bfe3e4 | ||
|
|
32f02088e6 | ||
|
|
cabe9cd12a | ||
|
|
381cdb1c41 | ||
|
|
62a7aa43b2 | ||
|
|
5c4e4bfbbb | ||
|
|
80e8eb29e8 | ||
|
|
acfe7c4f9d | ||
|
|
54517a2770 | ||
|
|
d2357d6518 | ||
|
|
c4184d772a | ||
|
|
55a0bb388e | ||
|
|
7fabc3bf1f | ||
|
|
5d78cc9b64 | ||
|
|
3a305aff29 | ||
|
|
81f1c62bc4 | ||
|
|
a43fae6a4c | ||
|
|
69fde9da42 | ||
|
|
f7c4dd4919 | ||
|
|
0d034d74c0 | ||
|
|
2dfaf32d28 | ||
|
|
c8a8765589 | ||
|
|
7d211170e8 | ||
|
|
f39fca787a | ||
|
|
08cc309553 | ||
|
|
c646007b8d | ||
|
|
9db6d4bd4d | ||
|
|
09be2d7175 | ||
|
|
7242f9f80d | ||
|
|
7d7041967c | ||
|
|
eb32bc3a80 | ||
|
|
dddd738519 | ||
|
|
fc3056f0cc | ||
|
|
d98e8d5428 | ||
|
|
a33f1cfc34 | ||
|
|
e7c4333dea | ||
|
|
81b877106b | ||
|
|
5b1d7fc6d9 | ||
|
|
fbbb9360e6 | ||
|
|
d6269fe4f7 | ||
|
|
1cef528c12 | ||
|
|
7c0f4d8e29 | ||
|
|
3822236568 | ||
|
|
75304d84ab | ||
|
|
1abaae9f04 | ||
|
|
f796d19712 | ||
|
|
57718aab70 | ||
|
|
7d58232332 | ||
|
|
0acc436bd0 | ||
|
|
c31e83fbe2 | ||
|
|
cbb4b4f78e | ||
|
|
80008cb4e7 | ||
|
|
d49bfaed2e | ||
|
|
3ab752d117 | ||
|
|
f51144970b | ||
|
|
25e2b0b539 | ||
|
|
95aa5f5dac | ||
|
|
85d3ebc7fb | ||
|
|
b5daa3a2b4 | ||
|
|
8df47f6c21 | ||
|
|
f06bf2af49 | ||
|
|
d26b28c430 | ||
|
|
6c20670f22 | ||
|
|
59bbf46689 | ||
|
|
1c99053aad | ||
|
|
4f6cfbfba7 | ||
|
|
49daffbf9d | ||
|
|
4c3d68b63e | ||
|
|
fa99216dd3 | ||
|
|
16ec189a0b | ||
|
|
c88504b47b | ||
|
|
d5d8e76a7a | ||
|
|
740c35783e | ||
|
|
c1e3204481 | ||
|
|
39b8df93cd | ||
|
|
447cd019b9 | ||
|
|
4e4b1fab15 | ||
|
|
98c1f05eff | ||
|
|
172696bb26 | ||
|
|
4793cca547 | ||
|
|
24adda1025 | ||
|
|
6dd3c3686b | ||
|
|
5e3e6a1664 | ||
|
|
214333d5e6 | ||
|
|
116e767c0d | ||
|
|
d695948d2e | ||
|
|
a9c3167802 | ||
|
|
d969588aac | ||
|
|
011443a76f | ||
|
|
a621a04b29 | ||
|
|
93c530d0a1 | ||
|
|
d0c98db2fa | ||
|
|
0750b2bb35 | ||
|
|
568395b78b | ||
|
|
741424dfce | ||
|
|
9d162e4302 | ||
|
|
ced26f4e4e | ||
|
|
690e7dc12a | ||
|
|
b8010abf82 | ||
|
|
6084a7ea64 | ||
|
|
708b1e0fc8 | ||
|
|
e39d966840 | ||
|
|
770eca9daf | ||
|
|
057b489107 | ||
|
|
0bc7681875 | ||
|
|
85c712f01a | ||
|
|
d33fa30f89 | ||
|
|
1e8bcf7cb1 | ||
|
|
b3cd2d21f4 | ||
|
|
c4d29c58fd | ||
|
|
6660722bd7 | ||
|
|
edf5e8831e | ||
|
|
b0af074fb4 | ||
|
|
9c9a09ff09 | ||
|
|
ea76eac0f8 | ||
|
|
27677b596c | ||
|
|
30511ab700 | ||
|
|
60e78a56ba | ||
|
|
10112d1df7 | ||
|
|
9601749fb2 | ||
|
|
3559537130 | ||
|
|
e976b344f3 | ||
|
|
ea7c30d799 | ||
|
|
c07d259323 | ||
|
|
de058a4af5 | ||
|
|
0969bedb2e | ||
|
|
e49c7a058a | ||
|
|
1e9ddb457e | ||
|
|
3ef7f901c6 | ||
|
|
0be32c92ce | ||
|
|
fddb02d016 | ||
|
|
359dd11d00 | ||
|
|
ebe27aae69 | ||
|
|
3be454f128 | ||
|
|
93b05c9b7b | ||
|
|
54cc0bd6aa | ||
|
|
12f4fdbde3 | ||
|
|
f35816ddfb | ||
|
|
ff320d9fe3 | ||
|
|
5baaa32273 | ||
|
|
3fd96e4882 | ||
|
|
547b3323d2 | ||
|
|
3dd010bca8 | ||
|
|
f242646a01 | ||
|
|
91c3ed747d | ||
|
|
62736cecb5 | ||
|
|
19b802a17b | ||
|
|
418801fac2 | ||
|
|
0aef24f747 | ||
|
|
7ee76db53c | ||
|
|
4bf858f3db | ||
|
|
8321127385 | ||
|
|
f0660d59c3 | ||
|
|
6f6d058c9f | ||
|
|
9201a07faf | ||
|
|
7996240f6f | ||
|
|
2c2859253d | ||
|
|
e8244a7853 | ||
|
|
17e1eb4d12 | ||
|
|
f7d5db67ad | ||
|
|
98b686059a | ||
|
|
54bcdbe510 | ||
|
|
64e70bb27d | ||
|
|
8aa8f0a048 | ||
|
|
05a2d0599a | ||
|
|
b371bf2bf4 | ||
|
|
0ac8d097b2 | ||
|
|
5b4cb56a27 | ||
|
|
fa20b72fea | ||
|
|
4af9ab4d0a | ||
|
|
409cb0f76c | ||
|
|
7a69c03135 | ||
|
|
7ce5965e46 | ||
|
|
18b154de9c | ||
|
|
7943f83c17 | ||
|
|
4f6b7810d5 | ||
|
|
fc94277775 | ||
|
|
02627f3d9f | ||
|
|
441d1eaf88 | ||
|
|
53d99b17f9 | ||
|
|
3cc1b9ef11 | ||
|
|
0b48c028f3 | ||
|
|
95707125c5 | ||
|
|
f57c5293bc | ||
|
|
3d0a6458da | ||
|
|
2b244d732e | ||
|
|
0659fab5f2 | ||
|
|
0477f8857d | ||
|
|
6e9bd00dbb | ||
|
|
98bdeeb88b | ||
|
|
5143cd2ed6 | ||
|
|
9a45ea3785 | ||
|
|
75a6364384 | ||
|
|
bd3fb7a809 | ||
|
|
ecfffa019a | ||
|
|
14dc093549 | ||
|
|
1065b81d05 |
455 changed files with 30038 additions and 2402 deletions
12
.github/workflows/windows.yml
vendored
12
.github/workflows/windows.yml
vendored
|
|
@ -216,11 +216,13 @@ jobs:
|
|||
run: |
|
||||
$payload = Resolve-Path 'packaging\windows\igneum-windows-app'
|
||||
function Run-Capture([string]$exe, [string]$flag) {
|
||||
$out = Join-Path $env:RUNNER_TEMP ('smoke-' + [IO.Path]::GetRandomFileName() + '.txt')
|
||||
$p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out
|
||||
$text = if (Test-Path $out) { (Get-Content $out -Raw) } else { '' }
|
||||
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $p.ExitCode, $text.Trim())
|
||||
if ($p.ExitCode -ne 0) { throw "$exe $flag exited $($p.ExitCode)" }
|
||||
# a direct call, not Start-Process -Wait -PassThru: a program that prints and exits at once raced the cmdlet
|
||||
# ("Cannot process request because the process has exited", 7 October 2026, the 0.3.21 run) and the read-back
|
||||
# was lost; the host program is a windows-subsystem exe, so its text comes through the same pipe
|
||||
$text = (& $exe $flag 2>&1 | Out-String)
|
||||
$code = $LASTEXITCODE
|
||||
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $code, $text.Trim())
|
||||
if ($code -ne 0) { throw "$exe $flag exited $code" }
|
||||
return $text
|
||||
}
|
||||
$v = $env:APP_VERSION
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -32,6 +32,10 @@ vendor/igneum-node-ship/
|
|||
brand/trademark/pbip-pack/
|
||||
brand/trademark/*.zip
|
||||
|
||||
# cargo target dirs of the shipper's local test runs and the build box's fetched artefacts (6 October 2026: a stray add -A staged 450 of them)
|
||||
app/igneum-app/target-tests/
|
||||
proving/igneum-prove/target-remote/
|
||||
**/target-remote/
|
||||
# the Discord bot and reddit bot dry-run renders
|
||||
tools/community/out/
|
||||
# the GPU workers built on igneum-build-1 (tools/workers-remote.sh); binaries, never committed
|
||||
|
|
|
|||
2
app/igneum-app/Cargo.lock
generated
2
app/igneum-app/Cargo.lock
generated
|
|
@ -219,7 +219,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "igneum-app"
|
||||
version = "0.3.14"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "igneum-app"
|
||||
version = "0.3.14"
|
||||
version = "0.3.23"
|
||||
edition = "2021"
|
||||
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
|
||||
license = "MIT"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
1 ICON "igneum.ico"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,3,14,0
|
||||
PRODUCTVERSION 0,3,14,0
|
||||
FILEVERSION 0,3,22,0
|
||||
PRODUCTVERSION 0,3,22,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
|
|
@ -20,12 +20,12 @@ BEGIN
|
|||
BEGIN
|
||||
VALUE "CompanyName", "Igneum"
|
||||
VALUE "FileDescription", "Igneum Miner engine"
|
||||
VALUE "FileVersion", "0.3.14"
|
||||
VALUE "FileVersion", "0.3.22"
|
||||
VALUE "InternalName", "igneum-app"
|
||||
VALUE "LegalCopyright", "Igneum contributors"
|
||||
VALUE "OriginalFilename", "igneum-app.exe"
|
||||
VALUE "ProductName", "Igneum Miner"
|
||||
VALUE "ProductVersion", "0.3.14"
|
||||
VALUE "ProductVersion", "0.3.22"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint
|
||||
//! igneum-ota-sign fingerprint <public-key-file|hex>
|
||||
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
|
||||
//! igneum-ota-sign sign-ui <private-key-file> <version> <sha256> <min-engine> the interface entry's own signature
|
||||
//! (src/manifest.rs ui_sign_bytes; tools/ui-ota/publish.mjs calls this), same key
|
||||
//! igneum-ota-sign verify-ui <public-key-file|hex|embedded> <version> <sha256> <min-engine> <signature-hex>
|
||||
//! igneum-ota-sign sign-jobs <private-key-file> <igneum-jobs.json> the remote-jobs file (src/jobs.rs), same key
|
||||
//! igneum-ota-sign verify-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file>
|
||||
//! igneum-ota-sign envelope-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file> prints igneum-jobs.signed.json:
|
||||
|
|
@ -18,6 +21,9 @@
|
|||
//! exit 0 only when the signature, the zip, every
|
||||
//! unpacked file and the pinned commit all check
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "../drivertable.rs"]
|
||||
mod drivertable;
|
||||
#[path = "../manifest.rs"]
|
||||
mod manifest;
|
||||
#[path = "../jobs.rs"]
|
||||
|
|
@ -101,6 +107,26 @@ fn main() {
|
|||
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
|
||||
println!("{sum} {size}");
|
||||
}
|
||||
Some("sign-ui") if args.len() == 5 => {
|
||||
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
|
||||
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
|
||||
let sk = SigningKey::from_bytes(&seed);
|
||||
if manifest::parse_version(&args[2]).is_none() || manifest::parse_version(&args[4]).is_none() {
|
||||
die("the version and the engine floor are versions like 0.3.19 or 0.3.19.1");
|
||||
}
|
||||
if args[3].len() != 64 || !args[3].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
die("the sha256 is 64 hex characters");
|
||||
}
|
||||
println!("{}", manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(&args[2], &args[3], &args[4])).to_bytes()));
|
||||
}
|
||||
Some("verify-ui") if args.len() == 6 => {
|
||||
let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) };
|
||||
let e = manifest::UiEntry { version: args[2].clone(), sha256: args[3].to_ascii_lowercase(), size: 1, url: "https://x".into(), min_engine: args[4].clone(), signature: args[5].to_ascii_lowercase() };
|
||||
match manifest::verify_ui_entry(&e, &pk) {
|
||||
Ok(()) => println!("verifies"),
|
||||
Err(e) => die(&e),
|
||||
}
|
||||
}
|
||||
Some("sign-jobs") if args.len() == 3 => {
|
||||
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
|
||||
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
|
||||
|
|
|
|||
349
app/igneum-app/src/boot.rs
Normal file
349
app/igneum-app/src/boot.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
//! The engine starts at boot without a logon (0.3.22, MF-11's second half). PC 2, 7 October 2026, 17:27 BST: a kernel
|
||||
//! crash during the Intel driver install, the PC back at 17:28, and the per-user app then waited 67 minutes for a
|
||||
//! logon with every card idle, because the only start was the Run key at logon and `--launch` always opened the window
|
||||
//! host, which has no desktop before a logon.
|
||||
//!
|
||||
//! Windows, from 0.3.22:
|
||||
//! - a per-user scheduled task `Igneum Miner (boot)` runs at system start under the user's own account with the S4U
|
||||
//! logon (no password stored, no logon needed, limited run level): `igneum-app.exe --launch --data-root <the
|
||||
//! user's %LOCALAPPDATA%\igneum>` (the data root is spelled out because an S4U session may not load the profile);
|
||||
//! registered by the engine at its start (unelevated, the user's own task) and again inside the one approved step
|
||||
//! the Power Helper uses, for the account that cannot register it alone;
|
||||
//! - `--launch` decides by the session: no interactive session (SESSIONNAME unset: session 0, the boot task, a
|
||||
//! service) runs the engine headless (`--boot`: no window host, no browser); a logon session opens the window host
|
||||
//! as before;
|
||||
//! - the window host, at logon, starts `igneum-app.exe --wrapper` as always; that engine finds the headless engine's
|
||||
//! app.url answering api/state and becomes a BRIDGE instead of a second engine: it prints the headless engine's
|
||||
//! URL and STATE lines to the host and relays the host's stdin commands (quit, pause, resume, detect) to the
|
||||
//! engine's API. The window closing (stdin gone) ends the bridge and leaves the engine mining; Quit in the tray
|
||||
//! quits the engine. A headless engine that stops answering ends the bridge with EXIT, and the host (0.3.21)
|
||||
//! starts `--wrapper` again, which then runs as a full engine.
|
||||
//! The decisions are functions here so the tests drive them with the known-failed shape first.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The boot task's name in the Windows Task Scheduler (per user).
|
||||
pub const TASK_NAME: &str = "Igneum Miner (boot)";
|
||||
/// How often the bridge reads api/state for a STATE line, and how many misses in a row end it.
|
||||
pub const BRIDGE_POLL_S: u64 = 3;
|
||||
pub const BRIDGE_MISSES: u32 = 4;
|
||||
|
||||
/// What `--launch` does.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LaunchMode {
|
||||
/// start "Igneum Miner.exe" (the window host), which starts the engine
|
||||
Host,
|
||||
/// run the engine here, no window host, no browser (no interactive session, or no host next to the exe)
|
||||
Headless,
|
||||
/// run the engine here and open the dashboard in the default browser (a logon session without the window host)
|
||||
Browser,
|
||||
}
|
||||
|
||||
/// The session a process runs in: `SESSIONNAME` is set in every interactive logon session ("Console", "RDP-Tcp#3")
|
||||
/// and unset in session 0 (services, S4U scheduled tasks at boot).
|
||||
pub fn interactive_session(session_name: Option<&str>) -> bool {
|
||||
session_name.map(|s| !s.trim().is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 0.3.22: the window host only when someone is logged on; headless otherwise.
|
||||
pub fn launch_mode(session_name: Option<&str>, host_exists: bool) -> LaunchMode {
|
||||
match (interactive_session(session_name), host_exists) {
|
||||
(true, true) => LaunchMode::Host,
|
||||
(true, false) => LaunchMode::Browser,
|
||||
(false, _) => LaunchMode::Headless,
|
||||
}
|
||||
}
|
||||
|
||||
/// Before 0.3.22, for the record: the host whenever it existed, whoever was or was not logged on.
|
||||
pub fn legacy_launch_mode(host_exists: bool) -> LaunchMode {
|
||||
if host_exists { LaunchMode::Host } else { LaunchMode::Browser }
|
||||
}
|
||||
|
||||
/// Does a closed stdin mean "the host went away" (quit)? Only for an engine a window host attached (`--wrapper`): a
|
||||
/// headless engine (`--boot`, the boot task, a job's `--launch` with no session) owns itself and has no host to lose.
|
||||
/// PC 2, 7 October 2026, 19:09 to 19:11 BST: four engines started a minute apart each quit 3 s after "node started" with
|
||||
/// "the window host went away (stdin closed)", their parent having closed the pipe at once.
|
||||
pub fn stdin_close_quits(wrapper: bool, headless: bool) -> bool {
|
||||
wrapper && !headless
|
||||
}
|
||||
|
||||
// ---- the boot task ---------------------------------------------------------------------------------------------------
|
||||
|
||||
fn ps_quote(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// The PowerShell that registers the boot task: trigger at system start, principal the signed-in user with the S4U
|
||||
/// logon (runs with nobody logged on, no password stored), limited run level, no time limit, one instance, hidden;
|
||||
/// the action is the installed exe with `--launch --data-root <root>`.
|
||||
pub fn register_script(exe: &Path, data_root: &Path) -> String {
|
||||
let exe_s = ps_quote(&exe.display().to_string());
|
||||
let dir = exe.parent().map(|d| ps_quote(&d.display().to_string())).unwrap_or_default();
|
||||
let root = ps_quote(&data_root.display().to_string());
|
||||
format!(
|
||||
"$a = New-ScheduledTaskAction -Execute '{exe_s}' -Argument '--launch --data-root \"{root}\"' -WorkingDirectory '{dir}'\r\n\
|
||||
$t = New-ScheduledTaskTrigger -AtStartup\r\n\
|
||||
$t.Delay = 'PT30S'\r\n\
|
||||
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Limited\r\n\
|
||||
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -MultipleInstances IgnoreNew -StartWhenAvailable -Hidden\r\n\
|
||||
Register-ScheduledTask -TaskName '{name}' -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null\r\n\
|
||||
exit 0\r\n",
|
||||
name = TASK_NAME
|
||||
)
|
||||
}
|
||||
|
||||
/// The PowerShell that says whether the boot task is registered, enabled and its action's exe present (exit 0).
|
||||
pub fn query_command() -> String {
|
||||
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}")
|
||||
}
|
||||
|
||||
/// The PowerShell that removes the task (an uninstall, or Start at login switched off).
|
||||
pub fn remove_command() -> String {
|
||||
format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue; exit 0")
|
||||
}
|
||||
|
||||
fn powershell(command: &str, limit: Duration) -> Option<(bool, String)> {
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]);
|
||||
crate::platform::quiet(&mut c);
|
||||
let out = crate::detect::run_timeout(&mut c, None, limit)?;
|
||||
// run_timeout folds the streams; the exit code is read again through a second probe when needed
|
||||
Some((true, out))
|
||||
}
|
||||
|
||||
/// Is the boot task registered (Windows only; false elsewhere)?
|
||||
pub fn registered() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]);
|
||||
crate::platform::quiet(&mut c);
|
||||
c.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Registers the boot task unelevated (the user's own task). Ok(true) registered now, Ok(false) already there,
|
||||
/// Err(why) when Windows refused (an account without the batch-logon right: the one approved step registers it).
|
||||
pub fn ensure_registered(exe: &Path, data_root: &Path) -> Result<bool, String> {
|
||||
if !cfg!(windows) {
|
||||
return Ok(false);
|
||||
}
|
||||
if registered() {
|
||||
return Ok(false);
|
||||
}
|
||||
let script = register_script(exe, data_root);
|
||||
let (_, out) = powershell(&script, Duration::from_secs(60)).ok_or("powershell did not answer")?;
|
||||
if registered() {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(format!("the boot task was not registered: {}", out.lines().last().unwrap_or("no reason given").trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed exe the task's action names (never a scratch copy), as the Power Helper picks it.
|
||||
pub fn task_exe(running: &Path) -> PathBuf {
|
||||
crate::powertask::task_exe(running, &crate::powertask::install_candidates())
|
||||
}
|
||||
|
||||
// ---- the bridge -------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// The engine already running under the user's data root: its URL when app.url names one that answers api/state.
|
||||
pub fn running_engine(app_dir: &Path) -> Option<String> {
|
||||
let url = std::fs::read_to_string(app_dir.join("app.url")).ok()?.trim().to_string();
|
||||
if !url.starts_with("http://127.0.0.1:") {
|
||||
return None;
|
||||
}
|
||||
let state = api_get(&url, "api/state")?;
|
||||
let v: serde_json::Value = serde_json::from_str(state.trim()).ok()?;
|
||||
v.get("version").and_then(|x| x.as_str()).map(|_| url)
|
||||
}
|
||||
|
||||
fn api_get(url: &str, path: &str) -> Option<String> {
|
||||
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "4", &format!("{url}{path}")]), None, Duration::from_secs(6)).filter(|o| !o.trim().is_empty())
|
||||
}
|
||||
|
||||
fn api_post(url: &str, path: &str) -> bool {
|
||||
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "6", "-X", "POST", "-H", "Content-Type: application/json", "-d", "{}", &format!("{url}{path}")]), None, Duration::from_secs(8)).is_some()
|
||||
}
|
||||
|
||||
/// The host's stdin line as the engine's API path; None for a line the bridge does not relay.
|
||||
pub fn command_path(line: &str) -> Option<&'static str> {
|
||||
match line.trim() {
|
||||
"quit" => Some("api/quit"),
|
||||
"pause" => Some("api/pause"),
|
||||
"resume" => Some("api/resume"),
|
||||
"detect" => Some("api/detect"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The STATE line the host reads (engine.rs wrapper_state), built from the engine's api/state reply.
|
||||
pub fn state_line(api_state: &serde_json::Value) -> String {
|
||||
let g = |p: &[&str]| -> serde_json::Value {
|
||||
let mut v = api_state;
|
||||
for k in p {
|
||||
v = match v.get(k) {
|
||||
Some(x) => x,
|
||||
None => return serde_json::Value::Null,
|
||||
};
|
||||
}
|
||||
v.clone()
|
||||
};
|
||||
serde_json::json!({
|
||||
"phase": g(&["phase"]), "mining": g(&["mining", "state"]), "paused": g(&["mining", "paused"]),
|
||||
"hash_total": g(&["mining", "hash_total"]), "accepted_total": g(&["mining", "accepted_total"]),
|
||||
"node": g(&["node", "state"]), "blocks": g(&["node", "blocks"]), "peers": g(&["node", "peers"]), "quitting": g(&["quitting"]),
|
||||
"update": g(&["update", "available"]), "bridge": true,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What the bridge does after one poll: carry on, or end (with EXIT when the engine is gone; silently when the host
|
||||
/// closed its end, which leaves the engine mining).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum BridgeStep {
|
||||
Continue,
|
||||
EngineGone,
|
||||
HostGone,
|
||||
}
|
||||
|
||||
pub fn bridge_step(misses: u32, stdin_closed: bool) -> BridgeStep {
|
||||
if stdin_closed {
|
||||
BridgeStep::HostGone
|
||||
} else if misses >= BRIDGE_MISSES {
|
||||
BridgeStep::EngineGone
|
||||
} else {
|
||||
BridgeStep::Continue
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the bridge until the host or the engine goes. Returns the process exit code.
|
||||
pub fn run_bridge(url: &str) -> i32 {
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
println!("URL {url}");
|
||||
let _ = std::io::stdout().flush();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let closed = closed.clone();
|
||||
let url = url.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(l) = line else { break };
|
||||
if let Some(p) = command_path(&l) {
|
||||
let _ = api_post(&url, p);
|
||||
}
|
||||
}
|
||||
closed.store(true, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
let mut misses = 0u32;
|
||||
loop {
|
||||
match api_get(url, "api/state").and_then(|s| serde_json::from_str::<serde_json::Value>(s.trim()).ok()) {
|
||||
Some(v) => {
|
||||
misses = 0;
|
||||
println!("STATE {}", state_line(&v));
|
||||
let _ = std::io::stdout().flush();
|
||||
if v.get("quitting").and_then(|q| q.as_bool()).unwrap_or(false) {
|
||||
// the engine is leaving (Quit from the tray relayed above, or its own update): the host wants EXIT
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
misses = BRIDGE_MISSES;
|
||||
}
|
||||
}
|
||||
None => misses += 1,
|
||||
}
|
||||
match bridge_step(misses, closed.load(Ordering::Relaxed)) {
|
||||
BridgeStep::Continue => std::thread::sleep(Duration::from_secs(BRIDGE_POLL_S)),
|
||||
BridgeStep::EngineGone => {
|
||||
println!("EXIT");
|
||||
let _ = std::io::stdout().flush();
|
||||
return 0;
|
||||
}
|
||||
BridgeStep::HostGone => return 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Known-failed first: before 0.3.22 a boot with nobody logged on opened the window host (no desktop, no engine);
|
||||
/// PC 2 sat 67 minutes idle after its 17:28 BST self-reboot on 7 October 2026.
|
||||
#[test]
|
||||
fn before_0322_no_logon_meant_the_host_and_no_engine() {
|
||||
assert_eq!(legacy_launch_mode(true), LaunchMode::Host, "the host regardless of the session");
|
||||
assert_eq!(launch_mode(None, true), LaunchMode::Headless, "0.3.22: no session, the engine runs headless");
|
||||
assert_eq!(launch_mode(Some(""), true), LaunchMode::Headless);
|
||||
}
|
||||
|
||||
/// Known-failed first: before 0.3.22 every `--wrapper` engine quit on a closed stdin, whoever had started it.
|
||||
#[test]
|
||||
fn a_headless_engine_never_reads_a_closed_stdin_as_the_host_leaving() {
|
||||
assert!(stdin_close_quits(true, false), "the window host's own engine: the host left, the engine follows (by design)");
|
||||
assert!(!stdin_close_quits(true, true), "0.3.22: headless, even with --wrapper on the line, stays up");
|
||||
assert!(!stdin_close_quits(false, false), "an engine with no host never had a stdin to lose");
|
||||
assert!(!stdin_close_quits(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_logon_session_keeps_the_window_host_or_the_browser() {
|
||||
assert_eq!(launch_mode(Some("Console"), true), LaunchMode::Host);
|
||||
assert_eq!(launch_mode(Some("RDP-Tcp#3"), true), LaunchMode::Host);
|
||||
assert_eq!(launch_mode(Some("Console"), false), LaunchMode::Browser);
|
||||
assert!(interactive_session(Some("Console")) && !interactive_session(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_boot_task_runs_at_startup_as_the_user_without_a_logon_and_names_the_data_root() {
|
||||
let s = register_script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\u\\AppData\\Local\\igneum"));
|
||||
assert!(s.contains("-Execute 'C:\\p\\Igneum Miner\\igneum-app.exe' -Argument '--launch --data-root \"C:\\u\\AppData\\Local\\igneum\"'"), "{s}");
|
||||
assert!(s.contains("New-ScheduledTaskTrigger -AtStartup"), "at system start, not at logon");
|
||||
assert!(s.contains("-LogonType S4U -RunLevel Limited"), "the user's own token with nobody logged on, never elevated");
|
||||
assert!(s.contains("-ExecutionTimeLimit (New-TimeSpan -Seconds 0)") && s.contains("-MultipleInstances IgnoreNew") && s.contains("-Hidden"));
|
||||
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
|
||||
assert!(!s.contains("powershell.exe") && !s.contains("cmd /c"), "the action is the exe itself: no console window at boot");
|
||||
let q = query_command();
|
||||
assert!(q.contains("Test-Path") && q.contains("-ne 'Disabled'") && q.contains("exit 1"), "{q}");
|
||||
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Miner (boot)'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_relays_the_hosts_commands_and_nothing_else() {
|
||||
assert_eq!(command_path("quit"), Some("api/quit"));
|
||||
assert_eq!(command_path(" pause \r"), Some("api/pause"));
|
||||
assert_eq!(command_path("resume"), Some("api/resume"));
|
||||
assert_eq!(command_path("detect"), Some("api/detect"));
|
||||
assert_eq!(command_path("elevated ok"), None, "the elevated answers belong to a real engine's host");
|
||||
assert_eq!(command_path("rm -rf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_state_line_is_the_wrapper_state() {
|
||||
let st: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22","phase":"dashboard","mining":{"state":"mining","paused":false,"hash_total":121.0,"accepted_total":95511},"node":{"state":"synced","blocks":165529,"peers":4},"quitting":false,"update":{"available":false}}"#).unwrap();
|
||||
let line: serde_json::Value = serde_json::from_str(&state_line(&st)).unwrap();
|
||||
assert_eq!(line["phase"], "dashboard");
|
||||
assert_eq!(line["mining"], "mining");
|
||||
assert_eq!(line["hash_total"], 121.0);
|
||||
assert_eq!(line["accepted_total"], 95511);
|
||||
assert_eq!(line["node"], "synced");
|
||||
assert_eq!(line["blocks"], 165529);
|
||||
assert_eq!(line["quitting"], false);
|
||||
assert_eq!(line["bridge"], true);
|
||||
let partial: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22"}"#).unwrap();
|
||||
assert!(state_line(&partial).contains("\"phase\":null"), "a missing field is null, never a panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_ends_with_exit_when_the_engine_is_gone_and_silently_when_the_host_is() {
|
||||
assert_eq!(bridge_step(0, false), BridgeStep::Continue);
|
||||
assert_eq!(bridge_step(BRIDGE_MISSES - 1, false), BridgeStep::Continue);
|
||||
assert_eq!(bridge_step(BRIDGE_MISSES, false), BridgeStep::EngineGone, "the host then starts a fresh --wrapper, which becomes a full engine");
|
||||
assert_eq!(bridge_step(0, true), BridgeStep::HostGone, "the window closed: the headless engine keeps mining");
|
||||
assert_eq!(bridge_step(BRIDGE_MISSES, true), BridgeStep::HostGone);
|
||||
}
|
||||
}
|
||||
73
app/igneum-app/src/bootcheck.rs
Normal file
73
app/igneum-app/src/bootcheck.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! Did this PC come up from a power loss or a hard reset? (MF-11, 7 October 2026: PC 2 dropped twice in one day with
|
||||
//! Kernel-Power 41 and EventLog 6008 at the next boot, no bugcheck, no dump, and nothing said so until a person read
|
||||
//! the event log.) Windows: the System log's event 41 (Kernel-Power, critical) or 6008 (EventLog, "the previous
|
||||
//! shutdown was unexpected") inside the last 15 minutes, read once at the engine's start through wevtutil; the engine
|
||||
//! logs one `FAULT pc-restart:` line to the intake. Other platforms: nothing (a Mac's power log is not this class).
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How far back the start-up check looks: an engine starts at login, inside a minute or two of the boot.
|
||||
pub const WINDOW_MS: u64 = 15 * 60 * 1000;
|
||||
|
||||
/// One line naming the event, or None when the boot was clean, the query failed, or this is not Windows.
|
||||
pub fn unexpected_restart() -> Option<String> {
|
||||
if !cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
let query = format!("*[System[(EventID=41 or EventID=6008) and TimeCreated[timediff(@SystemTime) <= {WINDOW_MS}]]]");
|
||||
let mut c = Command::new(crate::platform::tool("wevtutil"));
|
||||
c.args(["qe", "System", &format!("/q:{query}"), "/f:text", "/c:2", "/rd:true"]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(20))?;
|
||||
parse_events(&out)
|
||||
}
|
||||
|
||||
/// The reading of wevtutil's text output: the newest 41 or 6008 as "event 41 (Kernel-Power) at <time>" or
|
||||
/// "event 6008 (the previous shutdown was unexpected) at <time>"; None when neither is in the text.
|
||||
pub fn parse_events(text: &str) -> Option<String> {
|
||||
let mut date = String::new();
|
||||
let mut id = String::new();
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if let Some(d) = t.strip_prefix("Date:") {
|
||||
date = d.trim().to_string();
|
||||
} else if let Some(i) = t.strip_prefix("Event ID:") {
|
||||
id = i.trim().to_string();
|
||||
if id == "41" || id == "6008" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match id.as_str() {
|
||||
"41" => Some(format!("event 41 (Kernel-Power: the system rebooted without shutting down cleanly) at {date}")),
|
||||
"6008" => Some(format!("event 6008 (the previous shutdown was unexpected) at {date}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_events;
|
||||
|
||||
const PC2: &str = "Event[0]\n Log Name: System\n Source: Microsoft-Windows-Kernel-Power\n Date: 2026-10-07T14:37:19.4360000Z\n Event ID: 41\n Task: N/A\n Level: Critical\n Opcode: Info\n Keyword: N/A\n User: S-1-5-18\n Computer: X\n Description: \n\nEvent[1]\n Log Name: System\n Source: EventLog\n Date: 2026-10-07T14:37:29.0380000Z\n Event ID: 6008\n Task: None\n Level: Error\n";
|
||||
|
||||
/// PC 2's boot of 13:37Z on 7 October 2026 (the collection job's wevtutil text): the known-failed case reads as one.
|
||||
#[test]
|
||||
fn pc2_boot_reads_as_a_power_loss() {
|
||||
assert_eq!(parse_events(PC2), Some("event 41 (Kernel-Power: the system rebooted without shutting down cleanly) at 2026-10-07T14:37:19.4360000Z".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_6008_alone_is_the_unexpected_shutdown() {
|
||||
let t = "Event[0]\n Log Name: System\n Source: EventLog\n Date: 2026-10-07T08:22:34.0000000Z\n Event ID: 6008\n Level: Error\n";
|
||||
assert_eq!(parse_events(t), Some("event 6008 (the previous shutdown was unexpected) at 2026-10-07T08:22:34.0000000Z".into()));
|
||||
}
|
||||
|
||||
/// A clean boot (the known-good case): nothing, including other ids inside the window and an empty answer.
|
||||
#[test]
|
||||
fn a_clean_boot_reads_as_nothing() {
|
||||
assert_eq!(parse_events(""), None);
|
||||
assert_eq!(parse_events("Event[0]\n Log Name: System\n Source: Microsoft-Windows-Kernel-General\n Date: 2026-10-07T14:37:17.7400000Z\n Event ID: 12\n Level: Information\n"), None);
|
||||
assert_eq!(parse_events("wevtutil: access denied"), None);
|
||||
}
|
||||
}
|
||||
80
app/igneum-app/src/card.rs
Normal file
80
app/igneum-app/src/card.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! The saved block card (miner-ui-5): the page draws the 1200x630 PNG on a canvas from the same words the card
|
||||
//! shows (no network call), posts it as a data URL to POST /api/card, and the engine writes it under
|
||||
//! `<data root>/cards/`. This module is the pure part: the base64 decode (no crate for it) and the file name.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `data:image/png;base64,....` -> the PNG bytes; None for anything that is not a base64 PNG data URL.
|
||||
pub fn decode_data_url(s: &str) -> Option<Vec<u8>> {
|
||||
let rest = s.strip_prefix("data:image/png;base64,")?;
|
||||
let bytes = decode_base64(rest)?;
|
||||
if bytes.len() < 8 || bytes[..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with or without `=` padding, whitespace ignored). None on a bad character.
|
||||
pub fn decode_base64(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits = 0u32;
|
||||
for c in s.bytes() {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `<root>/cards/igneum-block-<name>-<unix>.png`, the name reduced to [a-z0-9-] and at most 40 characters.
|
||||
pub fn card_path(root: &Path, name: &str, unix: u64) -> PathBuf {
|
||||
let mut clean = String::new();
|
||||
for c in name.to_ascii_lowercase().chars() {
|
||||
if c.is_ascii_alphanumeric() { clean.push(c); } else if !clean.ends_with('-') { clean.push('-'); }
|
||||
}
|
||||
let clean: String = clean.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string();
|
||||
let clean = if clean.is_empty() { "block".to_string() } else { clean };
|
||||
root.join("cards").join(format!("igneum-block-{clean}-{unix}.png"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_with_and_without_padding() {
|
||||
assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello");
|
||||
assert_eq!(decode_base64("aGVsbG8").unwrap(), b"hello");
|
||||
assert_eq!(decode_base64("aGVs\nbG8gd29ybGQ=").unwrap(), b"hello world");
|
||||
assert!(decode_base64("aGV$sbG8=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_data_url_decodes_and_anything_else_is_refused() {
|
||||
// the 8-byte PNG signature, base64
|
||||
let sig = "iVBORw0KGgo=";
|
||||
let png = decode_data_url(&format!("data:image/png;base64,{sig}")).unwrap();
|
||||
assert_eq!(&png[..4], b"\x89PNG");
|
||||
assert!(decode_data_url("data:image/jpeg;base64,/9j/").is_none());
|
||||
assert!(decode_data_url("data:image/png;base64,aGVsbG8=").is_none(), "not a PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_name_is_safe_and_dated() {
|
||||
let p = card_path(Path::new("/data"), "Block 1,284,117 / RTX 4070", 1_791_362_116);
|
||||
assert_eq!(p, PathBuf::from("/data/cards/igneum-block-block-1-284-117-rtx-4070-1791362116.png"));
|
||||
assert_eq!(card_path(Path::new("/d"), "///", 1).file_name().unwrap(), "igneum-block-block-1.png");
|
||||
}
|
||||
}
|
||||
263
app/igneum-app/src/chainfacts.rs
Normal file
263
app/igneum-app/src/chainfacts.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! GET /api/ladder: the chain facts behind the miner's ladder (miner-ui-5, 7 October 2026). Every rung the dashboard
|
||||
//! shows is a number from the node's finality RPC, never a count this app keeps: `getFinalityWeights` (the per-key
|
||||
//! table: `keys[].blocks` in the weight window, `keys[].voter` against `params.dust`, `keys[].participation` over
|
||||
//! `params.presenceWindow`, `totalWeight`, `activeWeight`, `voters`, `checkpointIndex`) and `getFinalityCheckpoints`
|
||||
//! (`latestLockedIndex`, `nextIndex`, `finalityActive`). The network rate and the reward come from the public
|
||||
//! `/api/stats` (`hashrate`, `block_reward.miner_ign`, `block_reward.ramp_factor`), the observer's reading of the same
|
||||
//! node RPCs.
|
||||
//!
|
||||
//! Sources, in order: the local node's EVM port answering `igneum_getFinalityWeights` (OWED on the node: the finality
|
||||
//! RPCs are wRPC only today and the engine carries no websocket client), else the observer's `/api/live`, whose
|
||||
//! `finality.weights` is the node's `getFinalityWeights` relayed (field names in snake case, `keys[]` cut to 64, key ids
|
||||
//! as the first 8 hex of the key hash). The reply names its source so the dashboard can say it on hover. Cached 10 s.
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static CACHE: Mutex<Option<(Instant, Value)>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(10);
|
||||
/// the observer relays at most this many keys (tools/observer/observer.mjs)
|
||||
pub const OBSERVER_KEYS_CAP: usize = 64;
|
||||
|
||||
fn num(v: &Value) -> f64 {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(s) => s.parse().unwrap_or(0.0),
|
||||
Value::Bool(b) => if *b { 1.0 } else { 0.0 },
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
fn u(v: &Value) -> u64 { num(v).max(0.0) as u64 }
|
||||
fn get<'a>(v: &'a Value, keys: &[&str]) -> &'a Value {
|
||||
for k in keys {
|
||||
if let Some(x) = v.get(k) { return x; }
|
||||
}
|
||||
&Value::Null
|
||||
}
|
||||
|
||||
/// One key's row in either spelling (the node's camelCase or the observer's snake case).
|
||||
fn key_row(k: &Value) -> Value {
|
||||
let id_full = get(k, &["keyHash", "key_hash", "id"]).as_str().unwrap_or("").trim_start_matches("0x").to_ascii_lowercase();
|
||||
let id: String = id_full.chars().take(8).collect();
|
||||
let revealed = match k.get("revealed") { Some(r) => r.as_bool().unwrap_or(false), None => k.get("pubkey").and_then(|p| p.as_str()).map(|p| !p.is_empty()).unwrap_or(false) };
|
||||
json!({
|
||||
"id": id,
|
||||
"blocks": u(get(k, &["blocks"])),
|
||||
"voter": get(k, &["voter"]).as_bool().unwrap_or(false),
|
||||
"participation": num(get(k, &["participation"])),
|
||||
"stripped_until_daa": u(get(k, &["strippedUntilDaa", "stripped_until_daa"])),
|
||||
"revealed": revealed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `id` one of this machine's key ids? A prefix of the other of at least 6 hex counts (the engine keeps 8, a
|
||||
/// source may keep more or fewer).
|
||||
pub fn is_mine(id: &str, ids: &[String]) -> bool {
|
||||
let id = id.trim_start_matches("0x").to_ascii_lowercase();
|
||||
ids.iter().any(|m| {
|
||||
let m = m.trim_start_matches("0x").to_ascii_lowercase();
|
||||
let n = id.len().min(m.len());
|
||||
n >= 6 && id[..n] == m[..n]
|
||||
})
|
||||
}
|
||||
|
||||
/// The reply from a weights table (`w`: the node's `getFinalityWeights` reply, or the observer's
|
||||
/// `finality.weights` with `finality` beside it), the observer's `finality` block (params and the locked index), the
|
||||
/// public stats, and this machine's key ids. Pure.
|
||||
pub fn shape(w: &Value, finality: &Value, stats: &Value, ids: &[String], source: &str, read_at: f64) -> Value {
|
||||
let params = if w.get("params").is_some() { get(w, &["params"]) } else { get(finality, &["params"]) };
|
||||
let mut keys: Vec<Value> = get(w, &["keys"]).as_array().map(|a| a.iter().map(key_row).collect()).unwrap_or_default();
|
||||
keys.sort_by(|a, b| u(&b["blocks"]).cmp(&u(&a["blocks"])).then_with(|| a["id"].as_str().cmp(&b["id"].as_str())));
|
||||
let mut mine: Vec<Value> = Vec::new();
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if is_mine(k["id"].as_str().unwrap_or(""), ids) {
|
||||
let mut m = k.clone();
|
||||
m["rank"] = json!(i + 1);
|
||||
mine.push(m);
|
||||
}
|
||||
}
|
||||
let best = mine.iter().min_by_key(|m| u(&m["rank"])).cloned();
|
||||
let reward = get(stats, &["block_reward"]);
|
||||
let latest_locked = if finality.get("latest_locked_index").is_some() { u(get(finality, &["latest_locked_index"])) } else { u(get(get(stats, &["finality"]), &["latest_locked_index"])) };
|
||||
let active = match finality.get("active").or_else(|| finality.get("finality_active")) { Some(a) => a.as_bool().unwrap_or(false), None => get(get(stats, &["finality"]), &["active"]).as_bool().unwrap_or(false) };
|
||||
json!({
|
||||
"ok": true,
|
||||
"source": source,
|
||||
"read_at": read_at,
|
||||
"rpc": {
|
||||
"weights": "getFinalityWeights",
|
||||
"checkpoints": "getFinalityCheckpoints",
|
||||
"stats": "/api/stats",
|
||||
"relay": if source == "node" { "" } else { "igneum.network/api/live (the observer's copy of the node's reply)" }
|
||||
},
|
||||
"params": {
|
||||
"dust": u(get(params, &["dust"])),
|
||||
"weight_window": u(get(params, &["weightWindow", "weight_window"])),
|
||||
"presence_window": u(get(params, &["presenceWindow", "presence_window"])),
|
||||
"checkpoint_interval": u(get(params, &["checkpointInterval", "checkpoint_interval"])),
|
||||
"min_daa": u(get(params, &["minDaa", "min_daa"])),
|
||||
},
|
||||
"checkpoint_index": u(get(w, &["checkpointIndex", "checkpoint_index"])),
|
||||
"daa_score": u(get(w, &["daaScore", "daa_score"])),
|
||||
"latest_locked_index": latest_locked,
|
||||
"next_index": u(get(finality, &["nextIndex", "next_index"])),
|
||||
"finality_active": active,
|
||||
"total_weight": u(get(w, &["totalWeight", "total_weight"])),
|
||||
"active_weight": num(get(w, &["activeWeight", "active_weight"])),
|
||||
"voters": u(get(w, &["voters"])),
|
||||
"keys_listed": keys.len(),
|
||||
"keys_cap": if source == "node" { 0 } else { OBSERVER_KEYS_CAP },
|
||||
"network": {
|
||||
"hashrate_hps": num(get(stats, &["hashrate"])),
|
||||
"miner_ign_per_block": num(get(reward, &["miner_ign"])),
|
||||
"ign_per_block": num(get(reward, &["ign"])),
|
||||
"ramp_factor": num(get(reward, &["ramp_factor"])),
|
||||
"daa": u(get(stats, &["daa"])),
|
||||
"blocks_per_day_measured": u(get(stats, &["blocks_per_day_measured"])),
|
||||
"bps": 1,
|
||||
"stale": get(stats, &["stale"]).as_bool().unwrap_or(stats.is_null()),
|
||||
},
|
||||
"mine": mine,
|
||||
"best": best,
|
||||
})
|
||||
}
|
||||
|
||||
fn curl_json(url: &str) -> Option<Value> {
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "6", url]), None, Duration::from_secs(8))?;
|
||||
serde_json::from_str(out.trim()).ok()
|
||||
}
|
||||
|
||||
/// `https://igneum.network/api/live` -> `https://igneum.network/api/stats`
|
||||
pub fn stats_api_from(live_api: &str) -> String {
|
||||
match live_api.strip_suffix("/api/live") {
|
||||
Some(base) => format!("{base}/api/stats"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply for the dashboard, cached 10 s: the node first, the observer second, `{ok:false}` with the reason when
|
||||
/// neither answers.
|
||||
pub fn fetch(evm_port: u16, live_api: &str, ids: &[String]) -> Value {
|
||||
if let Some((at, v)) = CACHE.lock().unwrap().as_ref() {
|
||||
if at.elapsed() < TTL {
|
||||
let mut c = v.clone();
|
||||
c["cached_age_s"] = json!(at.elapsed().as_secs_f64().round());
|
||||
return c;
|
||||
}
|
||||
}
|
||||
let now = crate::platform::unix_now_f();
|
||||
let stats = if live_api.is_empty() { None } else { curl_json(&stats_api_from(live_api)) };
|
||||
let stats = stats.unwrap_or(Value::Null);
|
||||
let reply = match crate::extnode::rpc(evm_port, "igneum_getFinalityWeights", json!([]), Duration::from_secs(4)) {
|
||||
Some(w) if w.get("keys").is_some() => shape(&w, &Value::Null, &stats, ids, "node", now),
|
||||
_ => match if live_api.is_empty() { None } else { curl_json(&crate::live::url_for(live_api, 60)) } {
|
||||
Some(live) => {
|
||||
let fin = get(&live, &["finality"]);
|
||||
let w = get(fin, &["weights"]);
|
||||
if w.get("keys").is_some() { shape(w, fin, &stats, ids, "observer", now) } else { json!({ "ok": false, "error": "the observer carries no finality weights yet", "source": "observer" }) }
|
||||
}
|
||||
None => json!({ "ok": false, "error": if live_api.is_empty() { "no observer address in this build and the node has no igneum_getFinalityWeights" } else { "neither the node nor the observer answered" }, "source": "" }),
|
||||
},
|
||||
};
|
||||
*CACHE.lock().unwrap() = Some((Instant::now(), reply.clone()));
|
||||
reply
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn observer_live() -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"state": { "hashes_per_second_estimate": 764944722 },
|
||||
"finality": {
|
||||
"supported": true, "active": true, "next_index": 8496, "latest_locked_index": 8495,
|
||||
"params": { "dust": 5, "minDaa": 7200, "aggregators": 8, "weightWindow": 7200, "presenceWindow": 20, "checkpointDepth": 20, "equivocationBan": 7200, "checkpointInterval": 30 },
|
||||
"weights": {
|
||||
"checkpoint_index": 8495, "total_weight": 6000, "active_weight": 5900.5, "voters": 14,
|
||||
"keys": [
|
||||
{ "id": "6e80f3ef", "voter": true, "blocks": 680, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
|
||||
{ "id": "9e4ba6b0", "voter": true, "blocks": 559, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
|
||||
{ "id": "8fafda27", "voter": true, "blocks": 61, "revealed": true, "participation": 0.95, "stripped_until_daa": 0 },
|
||||
{ "id": "00000001", "voter": false, "blocks": 3, "revealed": false, "participation": 0, "stripped_until_daa": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn stats() -> Value {
|
||||
json!({ "ok": true, "stale": false, "daa": 266454, "hashrate": 764944722, "blocks_per_day_measured": 92280, "block_reward": { "miner_ign": "4.88044084", "ign": "6.10055105", "ramp_factor": 0.192519 }, "finality": { "active": true, "latest_locked_index": 8495 } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_observer_relay_shapes_into_the_node_fields_with_our_rank() {
|
||||
let live = observer_live();
|
||||
let fin = &live["finality"];
|
||||
let r = shape(&fin["weights"], fin, &stats(), &["8fafda27".into()], "observer", 1.0);
|
||||
assert_eq!(r["ok"], true);
|
||||
assert_eq!(r["source"], "observer");
|
||||
assert_eq!(r["params"]["dust"], 5);
|
||||
assert_eq!(r["params"]["weight_window"], 7200);
|
||||
assert_eq!(r["params"]["presence_window"], 20);
|
||||
assert_eq!(r["latest_locked_index"], 8495);
|
||||
assert_eq!(r["finality_active"], true);
|
||||
assert_eq!(r["voters"], 14);
|
||||
assert_eq!(r["keys_listed"], 4);
|
||||
assert_eq!(r["keys_cap"], 64);
|
||||
assert_eq!(r["network"]["hashrate_hps"], 764944722.0);
|
||||
assert_eq!(r["network"]["miner_ign_per_block"], 4.88044084);
|
||||
assert_eq!(r["mine"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(r["mine"][0]["rank"], 3);
|
||||
assert_eq!(r["mine"][0]["blocks"], 61);
|
||||
assert_eq!(r["mine"][0]["voter"], true);
|
||||
assert_eq!(r["best"]["id"], "8fafda27");
|
||||
assert!(r["rpc"]["relay"].as_str().unwrap().contains("observer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_node_reply_in_camel_case_shapes_the_same_and_names_no_relay() {
|
||||
let w = json!({
|
||||
"params": { "checkpointInterval": 30, "checkpointDepth": 20, "weightWindow": 2592000, "dust": 100, "presenceWindow": 240, "aggregators": 8, "equivocationBan": 2592000, "minDaa": 7200 },
|
||||
"checkpointIndex": 184220, "checkpointHash": "ab", "daaScore": 1284117, "totalWeight": 2000000, "activeWeight": 1900000.0, "voters": 1204,
|
||||
"keys": [
|
||||
{ "keyHash": "8fafda27aa11bb22cc33dd44", "pubkey": "a1", "blocks": 2592, "voter": true, "participation": 1.0, "strippedUntilDaa": 0 },
|
||||
{ "keyHash": "0000000100000000", "pubkey": "", "blocks": 44, "voter": false, "participation": 0.0, "strippedUntilDaa": 0 }
|
||||
]
|
||||
});
|
||||
let r = shape(&w, &Value::Null, &stats(), &["8fafda27".into()], "node", 2.0);
|
||||
assert_eq!(r["source"], "node");
|
||||
assert_eq!(r["keys_cap"], 0);
|
||||
assert_eq!(r["params"]["dust"], 100);
|
||||
assert_eq!(r["params"]["weight_window"], 2592000);
|
||||
assert_eq!(r["checkpoint_index"], 184220);
|
||||
assert_eq!(r["mine"][0]["id"], "8fafda27");
|
||||
assert_eq!(r["mine"][0]["rank"], 1);
|
||||
assert_eq!(r["mine"][0]["revealed"], true);
|
||||
assert_eq!(r["rpc"]["relay"], "");
|
||||
// the locked index falls back to the public stats when the node reply carries no checkpoints block
|
||||
assert_eq!(r["latest_locked_index"], 8495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_with_no_key_in_the_table_has_no_rank() {
|
||||
let live = observer_live();
|
||||
let fin = &live["finality"];
|
||||
let r = shape(&fin["weights"], fin, &Value::Null, &["deadbeef".into()], "observer", 1.0);
|
||||
assert_eq!(r["mine"].as_array().unwrap().len(), 0);
|
||||
assert!(r["best"].is_null());
|
||||
assert_eq!(r["network"]["stale"], true);
|
||||
assert_eq!(r["network"]["hashrate_hps"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_matching_takes_a_prefix_of_six_or_more() {
|
||||
assert!(is_mine("8fafda27", &["8fafda27aa11".into()]));
|
||||
assert!(is_mine("0x8fafda27aa", &["8fafda27".into()]));
|
||||
assert!(!is_mine("8fafd", &["8fafda27".into()]));
|
||||
assert!(!is_mine("8fafda28", &["8fafda27".into()]));
|
||||
assert_eq!(stats_api_from("https://igneum.network/api/live"), "https://igneum.network/api/stats");
|
||||
assert_eq!(stats_api_from(""), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,18 @@ pub struct CardPref {
|
|||
pub sweep_class: String,
|
||||
#[serde(default)]
|
||||
pub sweep_source: String,
|
||||
/// the chosen clock sat on the ladder's floor (the lowest step measured, not the optimum)
|
||||
#[serde(default)]
|
||||
pub sweep_floor: bool,
|
||||
/// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
|
||||
#[serde(default)]
|
||||
pub tune_goal: String,
|
||||
/// the untuned point the last FULL plan measured first (its step 0), kept across confirm plans so the row can
|
||||
/// read "saves 84 W, 0.15% of rate"; 0 = never measured
|
||||
#[serde(default)]
|
||||
pub sweep_before_watts: f64,
|
||||
#[serde(default)]
|
||||
pub sweep_before_mhs: f64,
|
||||
/// Ember 2: the memory clock the last tune chose (0 = the driver's default)
|
||||
#[serde(default)]
|
||||
pub sweep_mem_mhz: u32,
|
||||
|
|
@ -102,6 +114,35 @@ pub struct Settings {
|
|||
pub power_price_pence: f64,
|
||||
#[serde(default)]
|
||||
pub tune_climb: bool,
|
||||
/// Ember Heat (mission item 7, 7 October 2026; src/heat.rs): the region code the miner chose at first run or in
|
||||
/// Settings ("" = not chosen; the price above is in that region's minor unit per kWh, typed, never fetched), the
|
||||
/// heat-mode switch, the set point in degrees, the schedule (slots by minute of the day in the window's clock,
|
||||
/// `heat_tz_min` minutes east of UTC), the typed room reading and when it was typed (0 = none), and the learned
|
||||
/// idle offset of the card sensor above the room (0 = the default).
|
||||
#[serde(default)]
|
||||
pub region: String,
|
||||
/// the network step (0.3.23): the chain this machine runs, chosen at first run or in Settings ("devnet-3" |
|
||||
/// "testnet-1"; "" = not chosen, the package's own network). Read at start; a change takes effect at the next start.
|
||||
#[serde(default)]
|
||||
pub network: String,
|
||||
/// currency-21 (7 October 2026): the ISO 4217 code the miner chose in Settings ("" = follow the region, which
|
||||
/// follows the OS locale at first run); the price above is in hundredths of this unit per kWh. Survives restart here.
|
||||
#[serde(default)]
|
||||
pub currency: String,
|
||||
#[serde(default)]
|
||||
pub heat_on: bool,
|
||||
#[serde(default = "nineteen")]
|
||||
pub heat_set_c: f64,
|
||||
#[serde(default)]
|
||||
pub heat_schedule: Vec<crate::heat::Slot>,
|
||||
#[serde(default)]
|
||||
pub heat_tz_min: i32,
|
||||
#[serde(default)]
|
||||
pub heat_room_c: f64,
|
||||
#[serde(default)]
|
||||
pub heat_room_at: f64,
|
||||
#[serde(default)]
|
||||
pub heat_offset_c: f64,
|
||||
/// When this install first ran (unix s), for the "first hour after install" sweep.
|
||||
#[serde(default)]
|
||||
pub installed_at: u64,
|
||||
|
|
@ -120,6 +161,18 @@ pub struct Settings {
|
|||
/// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start.
|
||||
#[serde(default)]
|
||||
pub prove_default_applied: bool,
|
||||
/// miner-ui-5 (7 October 2026): the public address profile on the site is behind this opt-in; off by default, and
|
||||
/// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model).
|
||||
#[serde(default)]
|
||||
pub profile_public: bool,
|
||||
/// ui-ota (7 October 2026, src/uiota.rs): "Use the built-in interface": the embedded dashboard serves even when an
|
||||
/// over-the-air interface bundle is active. Default off.
|
||||
#[serde(default)]
|
||||
pub ui_builtin: bool,
|
||||
/// Prove instead of mining on a machine whose only NVIDIA card is under 12 GB (main's routing, 7 October 2026: a
|
||||
/// 10 GB card holds the prover or the miner, never both). Off by default; the 12 GB refusal stays while it is off.
|
||||
#[serde(default)]
|
||||
pub prove_instead: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
|
@ -128,13 +181,41 @@ fn one() -> u32 {
|
|||
fn balanced() -> String {
|
||||
"balanced".into()
|
||||
}
|
||||
fn nineteen() -> f64 {
|
||||
19.0
|
||||
}
|
||||
/// The network step's rule (0.3.23): a switch is refused when the network is unknown, when it names the testnet while the
|
||||
/// manifest has not opened it, and when the engine runs another network and the user has not confirmed what resets.
|
||||
pub fn network_switch(want: &str, running: &str, testnet_open: bool, confirmed: bool) -> Result<(), String> {
|
||||
if !["devnet-3", "testnet-1"].contains(&want) {
|
||||
return Err(format!("'{want}' is not a network this app knows"));
|
||||
}
|
||||
if want == "testnet-1" && !testnet_open {
|
||||
return Err("igneum-testnet-1 is not yet open: the choice waits for the manifest to open it".into());
|
||||
}
|
||||
if network_name(want) != running && !confirmed {
|
||||
return Err("switching the network needs the confirm: the node's data and the mining state reset at the next start".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The chain's name for a choice ("" = the package's own, read as Devnet 3 from 0.3.22).
|
||||
pub fn network_name(choice: &str) -> &'static str {
|
||||
match choice {
|
||||
"testnet-1" => "igneum-testnet-1",
|
||||
_ => "igneum-devnet-3",
|
||||
}
|
||||
}
|
||||
/// The seeds of igneum-testnet-1 (docs/plans/testnet-go.md: p2p 26811, chain id 4462). The node compiles no DNS seeders for
|
||||
/// the testnet (the node lane, 7 October 2026), so the engine passes these as --addpeer.
|
||||
pub const TESTNET_SEEDS: [&str; 3] = ["seed1.testnet.igneum.network:26811", "seed2.testnet.igneum.network:26811", "seed3.testnet.igneum.network:26811"];
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Settings {
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false }
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), network: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +307,14 @@ pub struct Packaged {
|
|||
/// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file.
|
||||
#[serde(default)]
|
||||
pub node_override_params: Option<serde_json::Value>,
|
||||
/// The network the package's node joins (7 October 2026, Devnet 3): a suffixed devnet (`--devnet-suffix=N`), its own
|
||||
/// genesis and p2p port, its own node datadir devnet-N beside the shared devnet's devnet-v4 (kept for the way back).
|
||||
/// Absent = the shared devnet. IGNEUM_APP_DEVNET_SUFFIX in the environment wins over it.
|
||||
#[serde(default)]
|
||||
pub node_devnet_suffix: Option<u32>,
|
||||
/// The package's default peers for that network (host:port); absent = the shared devnet's list.
|
||||
#[serde(default)]
|
||||
pub node_peers: Option<Vec<String>>,
|
||||
/// Where the key and the manifest came from, for the log header: "packaged", "file <name>" or "none". Never
|
||||
/// serialised (the packaged file does not carry them; nothing sends this struct to the UI).
|
||||
#[serde(skip)]
|
||||
|
|
@ -351,18 +440,34 @@ pub struct Runtime {
|
|||
|
||||
impl Runtime {
|
||||
pub fn from_env() -> Runtime {
|
||||
Self::from_env_with(None, None)
|
||||
}
|
||||
|
||||
/// `packaged_suffix` and `packaged_peers` are the package's network (config.rs Packaged); the environment wins.
|
||||
pub fn from_env_with(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>) -> Runtime {
|
||||
Self::from_env_with_choice(packaged_suffix, packaged_peers, "")
|
||||
}
|
||||
/// The network step: `choice` is settings.network ("testnet-1" turns the runtime to the testnet object, `--testnet
|
||||
/// --netsuffix=1` with the three seeds as peers; "devnet-3" or "" keeps the package's network); the environment wins inside.
|
||||
pub fn from_env_with_choice(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>, choice: &str) -> Runtime {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| "devnet".into());
|
||||
let testnet = choice == "testnet-1";
|
||||
let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| if testnet { "testnet".into() } else { "devnet".into() });
|
||||
let packaged_peers: Option<&[String]> = if testnet { None } else { packaged_peers };
|
||||
let packaged_suffix = if testnet { None } else { packaged_suffix };
|
||||
let rpc_port = env("IGNEUM_APP_RPC_PORT").and_then(|v| v.parse().ok()).unwrap_or(26610);
|
||||
let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611);
|
||||
let peers = match std::env::var("IGNEUM_APP_PEERS") {
|
||||
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
||||
// the public seed node first, then the project lead's Mac on the house LAN (devnet only)
|
||||
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "192.168.68.64:26611".to_string()],
|
||||
// the public seed node first, then the build box's hand nodes (main's decision (b), 6 October 2026, 19:1x UTC:
|
||||
// the hands move off the project lead's Mac to igneum-build-1), then the project lead's Mac on the house LAN (devnet only)
|
||||
Err(_) if packaged_peers.map(|p| !p.is_empty()).unwrap_or(false) => packaged_peers.unwrap().to_vec(),
|
||||
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "188.40.146.49:26611".to_string(), "192.168.68.64:26611".to_string()],
|
||||
Err(_) if network == "testnet" => TESTNET_SEEDS.iter().map(|s| s.to_string()).collect(),
|
||||
Err(_) => vec![],
|
||||
};
|
||||
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);
|
||||
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok());
|
||||
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok()).or(packaged_suffix);
|
||||
let root = crate::platform::data_root();
|
||||
let node_dir = match env("IGNEUM_APP_NODE_DIR") {
|
||||
Some(d) => PathBuf::from(d),
|
||||
|
|
@ -392,6 +497,42 @@ impl Runtime {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn network_switch_rules() {
|
||||
// the network step: unknown refused; the testnet refused until open; another network needs the confirm; the same network is fine
|
||||
assert!(super::network_switch("mainnet", "igneum-devnet-3", true, true).is_err());
|
||||
assert!(super::network_switch("testnet-1", "igneum-devnet-3", false, true).unwrap_err().contains("not yet open"));
|
||||
assert!(super::network_switch("testnet-1", "igneum-devnet-3", true, false).unwrap_err().contains("needs the confirm"));
|
||||
assert!(super::network_switch("testnet-1", "igneum-devnet-3", true, true).is_ok());
|
||||
assert!(super::network_switch("devnet-3", "igneum-devnet-3", false, false).is_ok(), "the network already running needs no confirm");
|
||||
assert_eq!(super::network_name(""), "igneum-devnet-3");
|
||||
assert_eq!(super::network_name("testnet-1"), "igneum-testnet-1");
|
||||
// a fresh settings file carries no choice; an old file without the field reads none too, so it keeps the package's network
|
||||
assert_eq!(super::Settings::default().network, "");
|
||||
let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap();
|
||||
assert_eq!(old.network, "");
|
||||
let chosen: super::Settings = serde_json::from_str(r#"{"network":"testnet-1"}"#).unwrap();
|
||||
assert_eq!(chosen.network, "testnet-1");
|
||||
let r = super::Runtime::from_env_with_choice(Some(3), None, "testnet-1");
|
||||
assert_eq!(r.network, "testnet");
|
||||
assert_eq!(r.devnet_suffix, None);
|
||||
assert!(r.peers.iter().any(|p| p.starts_with("seed1.testnet.igneum.network")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_round_trip() {
|
||||
// currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""
|
||||
let mut s = super::Settings::default();
|
||||
s.currency = "EUR".into();
|
||||
s.region = "de".into();
|
||||
let text = serde_json::to_string(&s).unwrap();
|
||||
let back: super::Settings = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.currency, "EUR");
|
||||
assert_eq!(back.region, "de");
|
||||
let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap();
|
||||
assert_eq!(old.currency, "");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tmp(name: &str, content: &str) -> PathBuf {
|
||||
|
|
@ -529,4 +670,19 @@ mod fixture_tests {
|
|||
Err(e) => panic!("the crate refuses the file: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_package_names_the_network_when_the_environment_is_silent() {
|
||||
use super::Runtime;
|
||||
// the test never sets IGNEUM_APP_DEVNET_SUFFIX or IGNEUM_APP_PEERS, so the package's values win
|
||||
let peers = vec!["188.40.146.49:26631".to_string(), "188.40.146.49:26671".to_string()];
|
||||
let r = Runtime::from_env_with(Some(3), Some(&peers));
|
||||
assert_eq!(r.devnet_suffix, Some(3));
|
||||
assert_eq!(r.peers, peers);
|
||||
assert!(r.node_dir.ends_with("devnet-3"), "{}", r.node_dir.display());
|
||||
// no package network: the shared devnet as before
|
||||
let r = Runtime::from_env_with(None, None);
|
||||
assert_eq!(r.devnet_suffix, None);
|
||||
assert!(r.node_dir.ends_with("devnet-v4"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,8 +111,11 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
|
|||
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
|
||||
pub fn looks_integrated(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains("arc") && n.contains("intel") {
|
||||
return arc_is_integrated(&n);
|
||||
}
|
||||
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
|
||||
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
|
||||
if integrated.iter().any(|k| n.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
|
||||
|
|
@ -123,6 +126,22 @@ pub fn looks_integrated(name: &str) -> bool {
|
|||
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
|
||||
}
|
||||
|
||||
/// An Intel name carrying "Arc" (lower-cased): the cards ("Arc(TM) A770", "Arc(TM) B580", "Arc(TM) Pro A60") are
|
||||
/// discrete; the Arc-branded processor graphics are integrated: the bare "Intel(R) Arc(TM) Graphics" of Meteor Lake
|
||||
/// and Arrow Lake, the "Arc(TM) 130V / 140V" of Lunar Lake and the "Arc(TM) 1xxT" of Panther Lake (approximate names
|
||||
/// from Intel's product pages, 7 October 2026; the B580 is the first Intel card in hand, docs/plans/intel-arc.md).
|
||||
fn arc_is_integrated(lower: &str) -> bool {
|
||||
let after = lower.split("arc").nth(1).unwrap_or("").trim_start_matches("(tm)").trim_start_matches("(r)").trim();
|
||||
let word = after.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or("");
|
||||
// "graphics" or nothing after "Arc": the processor graphics; "130v", "140t": digits first, the processor graphics
|
||||
if word.is_empty() || word == "graphics" || word.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
// "pro", "a770", "b580": a card. Anything else unknown is read as a card (on by default; a wrong call costs an
|
||||
// iGPU 8 identities at 1 to 2 MH/s, a card called integrated would sit off with no reason the owner can see)
|
||||
false
|
||||
}
|
||||
|
||||
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Adapter {
|
||||
|
|
@ -137,6 +156,11 @@ pub struct Adapter {
|
|||
pub pnp_id: String,
|
||||
/// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them
|
||||
pub bus: String,
|
||||
/// the DriverVersion property ("32.0.101.9034"); empty when none is bound (driver-check, 7 October 2026)
|
||||
pub driver: String,
|
||||
/// the device sits behind a USB4 or Thunderbolt router in its parent chain (an eGPU enclosure; PC 2's RTX 5060 Ti in a
|
||||
/// Razer Core X V2, 7 October 2026): the kind reads "external" and the Cards page says eGPU
|
||||
pub external: bool,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
|
|
@ -174,6 +198,9 @@ pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
|
|||
if a.ram_mb > 0 && a.ram_mb < 1024 {
|
||||
return "integrated";
|
||||
}
|
||||
if a.external {
|
||||
return "external";
|
||||
}
|
||||
}
|
||||
"discrete"
|
||||
}
|
||||
|
|
@ -186,6 +213,10 @@ pub fn vendor_of(name: &str) -> &'static str {
|
|||
"amd"
|
||||
} else if n.contains("apple") {
|
||||
"apple"
|
||||
} else if n.contains("intel") {
|
||||
// Arc cards and Intel processor graphics alike (the kind tells them apart); the first Intel card is the B580
|
||||
// on PC 1, 7 October 2026 (docs/plans/intel-arc.md)
|
||||
"intel"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
|
|
@ -208,18 +239,25 @@ pub fn parse_adapters(json: &str) -> Vec<Adapter> {
|
|||
(Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff),
|
||||
_ => String::new(),
|
||||
};
|
||||
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus }
|
||||
let external = r.get("External").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), driver: s(r, "DriverVersion"), bus, external }
|
||||
})
|
||||
.filter(|a| !a.name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The PowerShell behind adapters(): one object per adapter with the PCI bus and address and whether a USB4 or Thunderbolt
|
||||
/// router sits in the device's parent chain (External), which is how an eGPU enclosure shows (PC 2's Razer Core X V2 reads
|
||||
/// "USB4 Router (2.0), Razer - Core X V2", instance USB4\VID_8087&PID_5786...).
|
||||
pub const ADAPTERS_SCRIPT: &str = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; $ext = $false; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; try { $cur = $id; for ($i = 0; $i -lt 6 -and $cur; $i++) { $par = (Get-PnpDeviceProperty -InstanceId $cur -KeyName 'DEVPKEY_Device_Parent' -ErrorAction Stop).Data; if (-not $par) { break }; if (\"$par\" -match '^USB4\\\\|THUNDERBOLT|TBT') { $ext = $true; break }; $cur = $par } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; DriverVersion = $_.DriverVersion; PNPDeviceID = $id; BusNumber = $bus; Address = $addr; External = $ext } }; ConvertTo-Json -InputObject @($v) -Compress";
|
||||
|
||||
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
|
||||
#[cfg(windows)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
// one object per adapter, with the PCI bus number and address from the PnP properties (they name the card
|
||||
// the OpenCL worker's "pci" field names); @() keeps a single adapter an array
|
||||
let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress";
|
||||
// External: the parent chain (DEVPKEY_Device_Parent, up to 6 hops) holds a USB4 router or a Thunderbolt device
|
||||
let script = ADAPTERS_SCRIPT;
|
||||
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
|
||||
let start = out.find(|c| c == '[' || c == '{')?;
|
||||
Some(parse_adapters(&out[start..]))
|
||||
|
|
@ -440,6 +478,9 @@ impl ClDevice {
|
|||
"nvidia"
|
||||
} else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") {
|
||||
"amd"
|
||||
} else if self.vendor.contains("Intel") || self.name.contains("Intel") {
|
||||
// "Intel(R) Corporation" on the "Intel(R) OpenCL Graphics" platform (the Arc driver's runtime)
|
||||
"intel"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
|
|
@ -601,6 +642,8 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
c.bus = bus;
|
||||
c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = mem_mb;
|
||||
// driver-check: nvidia-smi's driver_version ("581.57"); the adapter row's DriverVersion is the fallback
|
||||
c.driver_os = parts.get(4).map(|v| v.trim().to_string()).filter(|v| !v.is_empty() && !v.contains("N/A")).or_else(|| adapter.map(|i| adapters[i].driver.clone())).unwrap_or_default();
|
||||
c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() };
|
||||
if !inp.cuda_worker {
|
||||
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
|
||||
|
|
@ -639,6 +682,8 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver);
|
||||
c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb };
|
||||
// driver-check: Windows' DriverVersion for the card (AMD "32.0.32015.2008", Intel "32.0.101.9034")
|
||||
c.driver_os = adapter.map(|i| adapters[i].driver.clone()).unwrap_or_default();
|
||||
c.path = "prebuilt".into();
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
|
|
@ -654,6 +699,7 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
|
||||
c.driver_os = a.driver.clone();
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
used.push(i);
|
||||
|
|
@ -670,6 +716,7 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = classify_kind(&a.name, Some(a)).into();
|
||||
c.driver_os = a.driver.clone();
|
||||
mark_unusable(&mut c, &problem);
|
||||
d.cards.push(c);
|
||||
}
|
||||
|
|
@ -683,7 +730,7 @@ pub fn detect(bins: &Bins) -> Detection {
|
|||
// processor for the integrated call, the PCI address and the names for the rows
|
||||
let adapters = adapters();
|
||||
// NVIDIA: nvidia-smi ships with the driver
|
||||
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id,driver_version", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() };
|
||||
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
|
||||
let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)));
|
||||
|
|
@ -788,6 +835,15 @@ mod tests {
|
|||
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
|
||||
// PC 2, 7 October 2026: the RTX 5060 Ti behind the Razer Core X V2's USB4 router is an eGPU, not a discrete card
|
||||
let egpu = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5060 Ti","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04&SUBSYS_8A111043&REV_A1\\31C898B6A12DB04800","BusNumber":11,"Address":0,"External":true}]"#);
|
||||
assert_eq!(egpu.len(), 1);
|
||||
assert!(egpu[0].external && egpu[0].bus == "0b:00.0");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&egpu[0])), "external");
|
||||
let inside = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"x","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04\\1","BusNumber":1,"Address":0}]"#);
|
||||
assert!(!inside[0].external, "no External field reads as inside the case");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&inside[0])), "discrete");
|
||||
assert!(ADAPTERS_SCRIPT.contains("DEVPKEY_Device_Parent") && ADAPTERS_SCRIPT.contains("USB4") && ADAPTERS_SCRIPT.contains("External = $ext"), "the script walks the parent chain");
|
||||
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
|
||||
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
|
||||
|
|
@ -809,6 +865,8 @@ mod tests {
|
|||
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
|
||||
assert!(!looks_integrated("Apple M5 Max"));
|
||||
assert_eq!(vendor_of("Apple M5 Max"), "apple");
|
||||
assert_eq!(vendor_of("Intel(R) Arc(TM) B580 Graphics"), "intel");
|
||||
assert_eq!(vendor_of("Intel(R) UHD Graphics 770"), "intel");
|
||||
assert_eq!(vendor_of("gfx1036"), "amd");
|
||||
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
|
||||
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
|
||||
|
|
@ -951,6 +1009,38 @@ mod tests {
|
|||
assert!(diff.removed.is_empty());
|
||||
}
|
||||
|
||||
/// The first Intel card (the B580 on PC 1, 7 October 2026, docs/plans/intel-arc.md): the Arc driver's OpenCL
|
||||
/// platform lists it as "Intel(R) Arc(TM) B580 Graphics" under vendor "Intel(R) Corporation" (the line shape is
|
||||
/// the worker's; the device name and vendor string are Intel's documented forms, approximate until the PC 1 job
|
||||
/// prints them). It becomes a discrete 12 GB card, vendor intel, worker OpenCL, on with 8 identities; the
|
||||
/// Arc-branded processor graphics stay integrated; the NVIDIA cards beside it are untouched.
|
||||
#[test]
|
||||
fn an_intel_arc_card_on_the_intel_platform_is_a_discrete_intel_card() {
|
||||
let list = String::from(PC1_LIST)
|
||||
+ " [4] Intel(R) Arc(TM) B580 Graphics | Intel(R) OpenCL Graphics (OpenCL 3.0 )\n GPU, vendor Intel(R) Corporation, driver 32.0.101.9034, OpenCL C 3.0, 160 compute units, 2850 MHz, pci 05:00.0\n global 12208 MiB, max alloc 4095 MiB, local 64 KiB, max work-group 1024, sub-group extension: cl_intel_subgroups\n";
|
||||
let (devs, _) = parse_opencl_list(&list);
|
||||
let arc = devs.iter().find(|d| d.name.contains("B580")).expect("the Arc line parses");
|
||||
assert_eq!(arc.vendor_word(), "intel");
|
||||
assert_eq!(arc.mem_mb, 12208);
|
||||
assert_eq!(arc.bus, "05:00.0");
|
||||
let d = assemble(pc1_inputs(&list));
|
||||
let c = d.cards.iter().find(|c| c.vendor == "intel").expect("an intel card");
|
||||
assert_eq!((c.name.as_str(), c.kind.as_str(), c.worker.as_str(), c.device.as_str(), c.enabled, c.identities), ("Intel(R) Arc(TM) B580 Graphics", "discrete", "OpenCL", "4", true, 8));
|
||||
assert_eq!(c.key, "intel:Intel(R) Arc(TM) B580 Graphics");
|
||||
assert_eq!(c.vram_mb, 12208);
|
||||
assert!(!c.sweep_supported, "no cap through OpenCL");
|
||||
assert!(c.sweep_note.contains("Intel Arc"), "{}", c.sweep_note);
|
||||
assert!(d.listed(c), "the OpenCL list answered, so a vanished Arc can be called removed");
|
||||
// the Arc names: cards discrete, processor graphics integrated
|
||||
for card in ["Intel(R) Arc(TM) B580 Graphics", "Intel(R) Arc(TM) A770 Graphics", "Intel(R) Arc(TM) A380 Graphics", "Intel(R) Arc(TM) Pro A60 Graphics", "Intel(R) Arc(TM) B570 Graphics"] {
|
||||
assert_eq!(classify_kind(card, None), "discrete", "{card}");
|
||||
}
|
||||
for igpu in ["Intel(R) Arc(TM) Graphics", "Intel(R) Arc(TM) 140V GPU (16GB)", "Intel(R) Arc(TM) 130V GPU", "Intel(R) Arc(TM) 140T GPU"] {
|
||||
assert_eq!(classify_kind(igpu, None), "integrated", "{igpu}");
|
||||
}
|
||||
assert_eq!(classify_kind("Intel(R) UHD Graphics 770", None), "integrated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_number_identical_cards() {
|
||||
let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")];
|
||||
|
|
|
|||
229
app/igneum-app/src/driverinstall.rs
Normal file
229
app/igneum-app/src/driverinstall.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
//! The unattended driver install (the rights-at-install step `driver-install-task`, 7 October 2026, 19:5x BST; the project lead's
|
||||
//! rule that evening: no PC job may need a click or a UAC prompt, and the Arc's 9034 retry on PC 2 was cancelled for
|
||||
//! it). What the installer registers once (src/rights.rs on boot-start-22 takes the id into RIGHTS): the Igneum Power
|
||||
//! Helper task (RunLevel Highest, the app's own exe with `--power-helper`) with a two-hour execution limit, so the
|
||||
//! elevated helper can run a vendor's driver installer to its end. Nothing else: no second task, no new firewall rule.
|
||||
//!
|
||||
//! The protocol, on the helper's one command file: `<seq> driver <vendor>` with a vendor WORD only (nvidia | amd |
|
||||
//! intel). The helper, elevated, resolves everything else itself from the signed table the manifest left at
|
||||
//! `<app data>/drivers.json` and the file the app downloaded into `<app data>/drivers/`: the size, the sha256 and the
|
||||
//! Authenticode signer must match the table and the signer must be one of the three vendors, or nothing runs. So a
|
||||
//! writer of cmd.txt can never choose what runs elevated (the Power Helper's rule since 6 October 2026). The helper
|
||||
//! runs the installer with the table's silent arguments, keeps its heartbeat during the run (cap 45 minutes), and
|
||||
//! writes `<seq> <vendor> exit <code> reboot <0|1>` to `driver-result.txt` in its folder. The app holds the vendor's
|
||||
//! cards before it asks (engine::driver_install), reads the result, and a "restart required" exit is the Restart now
|
||||
//! button: the app never restarts the machine by itself. When the task is not registered (an install before 0.3.22
|
||||
//! whose rights step has not run), the one-prompt path of src/drivers.rs stays as it was.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The right's id and sentence for src/rights.rs RIGHTS (an id never changes meaning; a new need is a new id).
|
||||
pub const RIGHT: (&str, &str) = ("driver-install-task", "the Igneum Power Helper task with a two-hour run limit: a vendor's driver installs unattended through it, the vendor's cards held, no prompt (src/driverinstall.rs)");
|
||||
pub const RESULT_FILE: &str = "driver-result.txt";
|
||||
pub const VENDORS: &[&str] = &["nvidia", "amd", "intel"];
|
||||
/// The Authenticode subjects a driver installer may carry to run elevated (the table's `signer` must match one too).
|
||||
pub const ALLOWED_SIGNERS: &[&str] = &["Intel Corporation", "NVIDIA Corporation", "Advanced Micro Devices"];
|
||||
/// How long the helper waits for the installer, and how long the app waits for the helper's result line.
|
||||
pub const INSTALL_CAP: Duration = Duration::from_secs(45 * 60);
|
||||
pub const RESULT_WAIT: Duration = Duration::from_secs(50 * 60);
|
||||
|
||||
/// The installer's registration for this right: the Power Helper task as src/powertask.rs registers it, with the
|
||||
/// execution limit raised from one hour to two (a 1 GB download that the app already did is not in it; the installer
|
||||
/// itself runs 2 to 15 minutes, and the helper's own idle exit is 20 minutes).
|
||||
pub fn register_script(exe: &Path) -> String {
|
||||
crate::powertask::register_script(exe).replace("-ExecutionTimeLimit (New-TimeSpan -Hours 1)", "-ExecutionTimeLimit (New-TimeSpan -Hours 2)")
|
||||
}
|
||||
|
||||
pub fn vendor_ok(v: &str) -> bool {
|
||||
VENDORS.contains(&v)
|
||||
}
|
||||
|
||||
/// The command line the app writes for the helper (the sequence from the one wire space).
|
||||
pub fn command_line(seq: u64, vendor: &str) -> String {
|
||||
format!("{seq} driver {vendor}\n")
|
||||
}
|
||||
|
||||
/// The file the helper runs for a vendor: the table's URL's last path segment inside the app's drivers folder.
|
||||
pub fn file_for(e: &crate::drivertable::VendorEntry, drivers_dir: &Path) -> PathBuf {
|
||||
let name = e.url.rsplit('/').next().filter(|n| !n.is_empty() && !n.contains("..") && !n.contains('\\')).unwrap_or("driver.exe");
|
||||
drivers_dir.join(name)
|
||||
}
|
||||
|
||||
/// The elevated check before anything runs: size and sha256 equal to the table, the signer one of the vendors and the
|
||||
/// table's own. Pure, so the box tests it.
|
||||
pub fn verify(e: &crate::drivertable::VendorEntry, size: u64, sha256: &str, signer_subject: &str) -> Result<(), String> {
|
||||
if size != e.size {
|
||||
return Err(format!("size {size} is not the table's {}", e.size));
|
||||
}
|
||||
if !sha256.eq_ignore_ascii_case(&e.sha256) {
|
||||
return Err("sha256 is not the table's: the file is not the one the manifest names".into());
|
||||
}
|
||||
if !ALLOWED_SIGNERS.iter().any(|s| signer_subject.contains(s)) {
|
||||
return Err(format!("the signer '{signer_subject}' is not a driver vendor"));
|
||||
}
|
||||
if !e.signer.is_empty() && !signer_subject.contains(&e.signer) {
|
||||
return Err(format!("the signer '{signer_subject}' is not the table's '{}'", e.signer));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The helper's result line and its reading.
|
||||
pub fn result_line(seq: u64, vendor: &str, code: i64, reboot: bool) -> String {
|
||||
format!("{seq} {vendor} exit {code} reboot {}\n", if reboot { 1 } else { 0 })
|
||||
}
|
||||
pub fn parse_result(text: &str, seq: u64) -> Option<(i64, bool)> {
|
||||
text.lines().rev().find_map(|l| {
|
||||
let p: Vec<&str> = l.split_whitespace().collect();
|
||||
match p.as_slice() {
|
||||
[s, _, "exit", c, "reboot", r] if s.parse::<u64>().ok() == Some(seq) => Some((c.parse().ok()?, *r == "1")),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Inside the elevated helper: resolve, verify, run, report. `dir` is the helper's folder (<app data>/app/sweep).
|
||||
pub fn run_in_helper(dir: &Path, seq: u64, vendor: &str, log: &dyn Fn(&str)) {
|
||||
let app_dir = dir.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| dir.to_path_buf());
|
||||
let outcome = run_in_helper_inner(&app_dir, dir, vendor, log);
|
||||
let (code, reboot) = match outcome {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log(&format!("{seq} driver {vendor}: refused: {e}"));
|
||||
(-2, false)
|
||||
}
|
||||
};
|
||||
let _ = std::fs::OpenOptions::new().append(true).create(true).open(dir.join(RESULT_FILE)).and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(result_line(seq, vendor, code, reboot).as_bytes())
|
||||
});
|
||||
log(&format!("{seq} driver {vendor}: exit {code} reboot {reboot}"));
|
||||
}
|
||||
|
||||
fn run_in_helper_inner(app_dir: &Path, helper_dir: &Path, vendor: &str, log: &dyn Fn(&str)) -> Result<(i64, bool), String> {
|
||||
if !vendor_ok(vendor) {
|
||||
return Err(format!("'{vendor}' is not a vendor word"));
|
||||
}
|
||||
let text = std::fs::read_to_string(app_dir.join("drivers.json")).map_err(|e| format!("no driver table at {}: {e}", app_dir.join("drivers.json").display()))?;
|
||||
let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("the driver table does not parse: {e}"))?;
|
||||
let table = crate::drivertable::Table::parse(&v)?;
|
||||
let e = table.entry(vendor).ok_or_else(|| format!("no {vendor} row in the table"))?.clone();
|
||||
let file = file_for(&e, &app_dir.join("drivers"));
|
||||
let size = std::fs::metadata(&file).map(|m| m.len()).map_err(|x| format!("no downloaded installer at {}: {x}", file.display()))?;
|
||||
let sha = crate::manifest::sha256_file(&file).map_err(|x| x.to_string())?;
|
||||
let subject = signer_subject(&file)?;
|
||||
verify(&e, size, &sha, &subject)?;
|
||||
log(&format!("driver {vendor}: {} verified (size, sha256, signer '{subject}'); running {} {}", file.display(), file.display(), e.args.join(" ")));
|
||||
let mut c = std::process::Command::new(&file);
|
||||
c.args(&e.args);
|
||||
crate::platform::quiet(&mut c);
|
||||
let mut child = c.spawn().map_err(|x| format!("the installer did not start: {x}"))?;
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
crate::powertask::beat(helper_dir, crate::platform::unix_now());
|
||||
match child.try_wait() {
|
||||
Ok(Some(st)) => {
|
||||
let code = st.code().map(|c| c as i64).unwrap_or(-1);
|
||||
let (reboot, _) = crate::drivertable::exit_meaning(code, &e);
|
||||
return Ok((code, reboot));
|
||||
}
|
||||
Ok(None) => {
|
||||
if started.elapsed() > INSTALL_CAP {
|
||||
let _ = child.kill();
|
||||
return Err(format!("the installer ran past {} minutes and was ended", INSTALL_CAP.as_secs() / 60));
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
Err(x) => return Err(format!("waiting on the installer: {x}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn signer_subject(path: &Path) -> Result<String, String> {
|
||||
let ps = format!("$s = Get-AuthenticodeSignature -LiteralPath '{}'; Write-Output ('status=' + $s.Status); Write-Output ('subject=' + $s.SignerCertificate.Subject)", path.display().to_string().replace('\'', "''"));
|
||||
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("PowerShell did not answer the signature check")?;
|
||||
let mut status = String::new();
|
||||
let mut subject = String::new();
|
||||
for l in out.lines() {
|
||||
if let Some(v) = l.strip_prefix("status=") { status = v.trim().to_string(); } else if let Some(v) = l.strip_prefix("subject=") { subject = v.trim().to_string(); }
|
||||
}
|
||||
if status != "Valid" {
|
||||
return Err(format!("the Authenticode signature is {status}, not Valid"));
|
||||
}
|
||||
Ok(subject)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn signer_subject(_path: &Path) -> Result<String, String> {
|
||||
Err("the installer runs on Windows only".into())
|
||||
}
|
||||
|
||||
/// The app's side: when the Power Helper task is registered, ask it and wait for the result line; None when it is not
|
||||
/// (the caller falls back to the one-prompt path). `file` is the verified download.
|
||||
pub fn via_helper(vendor: &str) -> Option<Result<(i64, bool), String>> {
|
||||
if !cfg!(windows) || !crate::powertask::registered() {
|
||||
return None;
|
||||
}
|
||||
let dir = crate::powertask::helper_dir();
|
||||
Some((|| {
|
||||
crate::powertask::ensure_running(&dir, Duration::from_secs(30))?;
|
||||
let seq = crate::powertask::wire_seq();
|
||||
let mut text = std::fs::read_to_string(dir.join("cmd.txt")).unwrap_or_default();
|
||||
text.push_str(&command_line(seq, vendor));
|
||||
std::fs::write(dir.join("cmd.txt"), text).map_err(|e| e.to_string())?;
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
let res = std::fs::read_to_string(dir.join(RESULT_FILE)).unwrap_or_default();
|
||||
if let Some(r) = parse_result(&res, seq) {
|
||||
return Ok(r);
|
||||
}
|
||||
if started.elapsed() > RESULT_WAIT {
|
||||
return Err(format!("the Power Helper gave no result for the {vendor} install within {} minutes", RESULT_WAIT.as_secs() / 60));
|
||||
}
|
||||
if !crate::powertask::alive(&dir) && started.elapsed() > Duration::from_secs(120) {
|
||||
return Err("the Power Helper stopped during the install (no heartbeat)".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn intel() -> crate::drivertable::VendorEntry {
|
||||
let v: serde_json::Value = serde_json::from_str(include_str!("../../../packaging/ota/drivers.json")).unwrap();
|
||||
crate::drivertable::Table::parse(&v).unwrap().entry("intel").unwrap().clone()
|
||||
}
|
||||
|
||||
/// Known-failed first: an installer that is not the table's file, or not signed by a driver vendor, must never run
|
||||
/// elevated; the first cut of the unattended path had no such check.
|
||||
#[test]
|
||||
fn the_helper_refuses_a_file_that_is_not_the_tables_or_not_the_vendors() {
|
||||
let e = intel();
|
||||
assert!(verify(&e, e.size, &e.sha256, "CN=Intel Corporation, O=Intel Corporation, S=California, C=US").is_ok());
|
||||
assert!(verify(&e, e.size + 1, &e.sha256, "CN=Intel Corporation").unwrap_err().starts_with("size"));
|
||||
assert!(verify(&e, e.size, "00", "CN=Intel Corporation").unwrap_err().starts_with("sha256"));
|
||||
assert!(verify(&e, e.size, &e.sha256, "CN=Some Miner Tools Ltd").unwrap_err().contains("not a driver vendor"));
|
||||
assert!(verify(&e, e.size, &e.sha256, "CN=NVIDIA Corporation").unwrap_err().contains("not the table's"), "a vendor, but not this row's");
|
||||
assert_eq!(file_for(&e, Path::new("D")).file_name().unwrap().to_str().unwrap(), "gfx_win_101.9034.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_right_is_the_power_helper_task_with_a_two_hour_limit_and_the_protocol_round_trips() {
|
||||
assert_eq!(RIGHT.0, "driver-install-task", "the id src/rights.rs's test already anticipates");
|
||||
let s = register_script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"));
|
||||
assert!(s.contains("-ExecutionTimeLimit (New-TimeSpan -Hours 2)") && !s.contains("-Hours 1"), "{s}");
|
||||
assert!(s.contains("-TaskName 'Igneum Power Helper'") && s.contains("--power-helper"), "the same task, the same action");
|
||||
assert_eq!(command_line(305327, "intel"), "305327 driver intel\n");
|
||||
let r = result_line(305327, "intel", 14, true);
|
||||
assert_eq!(parse_result(&r, 305327), Some((14, true)));
|
||||
assert_eq!(parse_result(&r, 305328), None, "another sequence's result is not this one");
|
||||
assert_eq!(parse_result("305327 intel exit -2 reboot 0\n", 305327), Some((-2, false)), "a refusal reads as exit -2");
|
||||
assert!(vendor_ok("amd") && !vendor_ok("apple") && !vendor_ok("C:\\x.exe"));
|
||||
}
|
||||
}
|
||||
223
app/igneum-app/src/drivers.rs
Normal file
223
app/igneum-app/src/drivers.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Driver check (branch driver-check, 7 October 2026; the project lead: "can we package the drivers with the miner? for all
|
||||
//! cards? and the system knows which to install if not present"). The answer given: not bundled (size, vendor
|
||||
//! licences, staleness), but detected and installed on one click.
|
||||
//!
|
||||
//! The manifest carries a per-vendor table (`drivers`, signed with the rest): the version the worker needs at least,
|
||||
//! the version on offer, the vendor's own download URL, size, sha256 and the page the hash was read from, the
|
||||
//! installer's silent arguments and the exit codes that mean "restart required". The app never ships a driver: the
|
||||
//! table rides the manifest (ota.rs writes `<app data>/drivers.json`), so a new vendor release is a manifest publish.
|
||||
//!
|
||||
//! At every detection each card's driver version (nvidia-smi's for NVIDIA, Windows' DriverVersion for AMD and Intel)
|
||||
//! is compared with the table; a missing or old driver puts an offer on the card's row ("Install the NVIDIA driver
|
||||
//! 581.57, 650 MB"). The click downloads from the vendor's server (curl with resume), checks size, sha256 and the
|
||||
//! Authenticode signature (the signer must be the vendor), then runs the installer through ONE elevated prompt
|
||||
//! (platform::elevated_command, the PC 1 driver job's fetch, verify and -s shape), reports "restart required" with a
|
||||
//! Restart now button and never restarts by itself. Nothing else pauses: the miners keep mining through it.
|
||||
//! macOS: no driver step (the row says so). Linux and HiveOS: a line naming the package, no installer.
|
||||
//!
|
||||
//! Dry run (`IGNEUM_DRIVER_DRY_RUN=1`, or `dry_run: true` in the table): the download and every check run, the
|
||||
//! installer does not: the install step reports what it would have run and exit 0. The tests feed the table a
|
||||
//! 127.0.0.1 URL served by a std TcpListener (the mocked vendor response).
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub use crate::drivertable::{exit_meaning, offer_for, platform_word, DriverState, Offer, Table, VendorEntry};
|
||||
#[cfg(windows)]
|
||||
use crate::drivertable::authenticode_verdict;
|
||||
|
||||
/// The install thread's reports.
|
||||
pub enum Event {
|
||||
Progress(f64, String),
|
||||
/// the installer ran: its exit code and whether that means a restart
|
||||
Installed(Result<(i64, bool, String), String>),
|
||||
}
|
||||
|
||||
fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
|
||||
let mut c = Command::new(crate::platform::tool("curl"));
|
||||
c.args(args);
|
||||
let out = crate::detect::run_timeout(&mut c, None, limit).ok_or("curl is not available")?;
|
||||
let t = out.trim().to_string();
|
||||
if t.is_empty() { Ok(()) } else { Err(t.lines().last().unwrap_or("curl failed").to_string()) }
|
||||
}
|
||||
|
||||
/// Downloads the vendor's file with resume into `dir`, checks size and sha256, renames `.part` to the final name.
|
||||
pub fn download(e: &VendorEntry, dir: &Path) -> Result<PathBuf, String> {
|
||||
let name = e.url.rsplit('/').next().filter(|n| !n.is_empty() && !n.contains("..")).unwrap_or("driver.exe").to_string();
|
||||
let final_path = dir.join(&name);
|
||||
let part = dir.join(format!("{name}.part"));
|
||||
std::fs::create_dir_all(dir).map_err(|x| format!("cannot make {}: {x}", dir.display()))?;
|
||||
if final_path.is_file() && std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0) == e.size && crate::manifest::sha256_file(&final_path).map(|s| s == e.sha256).unwrap_or(false) {
|
||||
return Ok(final_path);
|
||||
}
|
||||
let _ = std::fs::remove_file(&final_path);
|
||||
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 {
|
||||
// drivers.amd.com answers 403 without an amd.com Referer (igneum-build-2, 7 October 2026): the row names one
|
||||
let mut args = vec!["-fsSL", "--retry", "3", "--retry-delay", "5", "-C", "-", "--max-time", "7200", "-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) IgneumMiner"];
|
||||
if !e.referer.is_empty() {
|
||||
args.push("-e");
|
||||
args.push(&e.referer);
|
||||
}
|
||||
let part_s = part.display().to_string();
|
||||
args.extend(["-o", &part_s, &e.url]);
|
||||
curl(&args, Duration::from_secs(7260))?;
|
||||
}
|
||||
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
|
||||
if got != e.size {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(format!("size mismatch: got {got} bytes, the table says {}", e.size));
|
||||
}
|
||||
let sum = crate::manifest::sha256_file(&part).map_err(|x| x.to_string())?;
|
||||
if sum != e.sha256 {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(format!("sha256 mismatch: the file is not the one the table names (page {})", e.hash_source));
|
||||
}
|
||||
std::fs::rename(&part, &final_path).map_err(|x| x.to_string())?;
|
||||
Ok(final_path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn authenticode(path: &Path, signer: &str) -> Result<String, String> {
|
||||
let ps = format!("$s = Get-AuthenticodeSignature -LiteralPath '{}'; Write-Output ('status=' + $s.Status); Write-Output ('subject=' + $s.SignerCertificate.Subject)", path.display().to_string().replace('\'', "''"));
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]), None, Duration::from_secs(60)).ok_or("PowerShell did not answer the signature check")?;
|
||||
authenticode_verdict(&out, signer)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn authenticode(_path: &Path, _signer: &str) -> Result<String, String> {
|
||||
Err("the installer runs on Windows only".into())
|
||||
}
|
||||
|
||||
/// The click: download, verify, run elevated (one prompt), report. Runs on its own thread; the miners keep mining.
|
||||
pub fn start_install(shared: &Arc<Shared>, e: VendorEntry, dir: PathBuf, dry_run: bool) {
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
shared2.send(Cmd::Driver(Event::Progress(0.05, format!("downloading {} ({} MB) from {}", e.version, (e.size + 512 * 1024) / (1024 * 1024), host_of(&e.url)))));
|
||||
let file = match download(&e, &dir) {
|
||||
Ok(f) => f,
|
||||
Err(x) => {
|
||||
shared2.send(Cmd::Driver(Event::Installed(Err(format!("download: {x}")))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
shared2.send(Cmd::Driver(Event::Progress(0.6, "size and sha256 match the table; checking the signature".into())));
|
||||
if !dry_run {
|
||||
if let Err(x) = authenticode(&file, &e.signer) {
|
||||
shared2.send(Cmd::Driver(Event::Installed(Err(x))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let unattended = !dry_run && cfg!(windows) && crate::powertask::registered();
|
||||
shared2.send(Cmd::Driver(Event::Progress(0.7, format!("{}installing {} {}: {}", if dry_run { "dry run: " } else { "" }, e.vendor.to_ascii_uppercase(), e.version, if unattended { "through the Igneum Power Helper task, no prompt" } else { "Windows asks for permission once" }))));
|
||||
if dry_run {
|
||||
shared2.send(Cmd::Driver(Event::Installed(Ok((0, false, format!("dry run: would run {} {}", file.display(), e.args.join(" ")))))));
|
||||
return;
|
||||
}
|
||||
// 0.3.22 (src/driverinstall.rs, the rights step driver-install-task): with the Power Helper task registered the
|
||||
// elevated helper runs the installer unattended after its own verification of the file; no prompt
|
||||
if unattended {
|
||||
if let Some(r) = crate::driverinstall::via_helper(&e.vendor) {
|
||||
let r = r.map(|(code, _)| { let (reboot, text) = exit_meaning(code, &e); (code, reboot, text) });
|
||||
shared2.send(Cmd::Driver(Event::Installed(r)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let args = e.args.join(" ");
|
||||
let mut c = crate::platform::elevated_command(&file.display().to_string(), &args);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(1800));
|
||||
let code = out.as_deref().and_then(|t| t.lines().rev().find_map(|l| l.trim().parse::<i64>().ok())).unwrap_or(-1);
|
||||
// elevated_ps_line prints nothing on success and exits with the installer's code; run_timeout only gives
|
||||
// stdout, so the exit code is read from the wrapper's own echo below
|
||||
let code = exit_code_of(&file, &args, code);
|
||||
let (reboot, text) = exit_meaning(code, &e);
|
||||
shared2.send(Cmd::Driver(Event::Installed(Ok((code, reboot, text)))));
|
||||
});
|
||||
}
|
||||
|
||||
/// The elevated wrapper's exit code: `elevated_ps_line` exits with the installer's code, which run_timeout does not
|
||||
/// return; so the installer is run through a second form that echoes the code on its last line.
|
||||
#[cfg(windows)]
|
||||
fn exit_code_of(file: &Path, args: &str, _seen: i64) -> i64 {
|
||||
let line = format!("{}; Write-Output ('exit=' + $LASTEXITCODE)", crate::platform::elevated_ps_line(&file.display().to_string(), args).trim_end_matches("exit $p.ExitCode").to_string() + "$global:LASTEXITCODE = $p.ExitCode");
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &line]);
|
||||
crate::platform::quiet(&mut c);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(1800)).unwrap_or_default();
|
||||
out.lines().rev().find_map(|l| l.trim().strip_prefix("exit=").and_then(|v| v.parse::<i64>().ok())).unwrap_or(-1)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn exit_code_of(_file: &Path, _args: &str, seen: i64) -> i64 {
|
||||
seen
|
||||
}
|
||||
|
||||
fn host_of(url: &str) -> String {
|
||||
url.split("//").nth(1).and_then(|r| r.split('/').next()).unwrap_or("the vendor").to_string()
|
||||
}
|
||||
|
||||
/// "Restart now": a plain restart in 20 s (no elevation needed for the signed-in user); never called by the app itself.
|
||||
pub fn restart_now() -> Result<(), String> {
|
||||
if !cfg!(windows) {
|
||||
return Err("restart from the app is for Windows only".into());
|
||||
}
|
||||
let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
let mut c = Command::new(format!("{root}\\System32\\shutdown.exe"));
|
||||
c.args(["/r", "/t", "20", "/c", "Igneum Miner: restarting to finish the driver install"]);
|
||||
crate::platform::quiet(&mut c);
|
||||
match crate::detect::run_timeout(&mut c, None, Duration::from_secs(20)) {
|
||||
Some(t) if t.trim().is_empty() => Ok(()),
|
||||
Some(t) => Err(t.trim().to_string()),
|
||||
None => Err("shutdown.exe did not answer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_tests {
|
||||
use super::*;
|
||||
|
||||
/// The mocked vendor response: a std TcpListener on 127.0.0.1 serves the file; the right sha256 passes, a wrong
|
||||
/// one is refused and the part file is gone; a second call with the file in place downloads nothing.
|
||||
#[test]
|
||||
fn download_verifies_against_a_mocked_vendor_server() {
|
||||
use std::io::{Read, Write};
|
||||
let body: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let served = body.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().take(3) {
|
||||
let mut s = stream.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = s.read(&mut buf);
|
||||
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n", served.len());
|
||||
let _ = s.write_all(head.as_bytes());
|
||||
let _ = s.write_all(&served);
|
||||
}
|
||||
});
|
||||
let dir = std::env::temp_dir().join(format!("igneum-driver-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
use sha2::Digest;
|
||||
let sum = format!("{:x}", sha2::Sha256::digest(&body));
|
||||
let mut e = VendorEntry { vendor: "intel".into(), version: "1.0".into(), min_version: "1.0".into(), url: format!("http://127.0.0.1:{port}/gfx_test.exe"), size: body.len() as u64, sha256: sum.clone(), args: vec!["-s".into()], signer: "Intel".into(), ..Default::default() };
|
||||
let f = download(&e, &dir).expect("the right sha256 passes");
|
||||
assert_eq!(f.file_name().unwrap(), "gfx_test.exe");
|
||||
assert_eq!(std::fs::read(&f).unwrap(), body);
|
||||
assert!(download(&e, &dir).is_ok(), "the file in place is kept without a second download");
|
||||
let _ = std::fs::remove_file(&f);
|
||||
e.sha256 = "0".repeat(64);
|
||||
let err = download(&e, &dir).unwrap_err();
|
||||
assert!(err.contains("sha256 mismatch"), "{err}");
|
||||
assert!(!dir.join("gfx_test.exe.part").exists() && !dir.join("gfx_test.exe").exists());
|
||||
e.sha256 = sum;
|
||||
e.size = 7;
|
||||
assert!(download(&e, &dir).unwrap_err().contains("size mismatch"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
395
app/igneum-app/src/drivertable.rs
Normal file
395
app/igneum-app/src/drivertable.rs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
//! The driver table and the offer decision (branch driver-check, 7 October 2026): the pure half of src/drivers.rs,
|
||||
//! with no dependency on the engine, so the signer binary (src/bin/ota-sign.rs) validates a manifest's `drivers`
|
||||
//! object the way the app does. The install thread, the download and the Authenticode call live in drivers.rs.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// One vendor's row of the table.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct VendorEntry {
|
||||
pub vendor: String,
|
||||
/// the lowest version the worker runs on (NVIDIA: nvidia-smi's "570.00"; AMD and Intel: Windows' DriverVersion "32.0.101.9034")
|
||||
pub min_version: String,
|
||||
/// what the click installs
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
/// where the sha256 was read (the vendor's page), for the record on the row
|
||||
pub hash_source: String,
|
||||
/// a Referer the vendor's server requires (drivers.amd.com answers 403 without an amd.com one); empty = none
|
||||
pub referer: String,
|
||||
/// the installer's silent arguments
|
||||
pub args: Vec<String>,
|
||||
/// exit codes that mean "installed, restart required"
|
||||
pub reboot_codes: Vec<i64>,
|
||||
/// a word the Authenticode subject must carry ("NVIDIA", "Advanced Micro Devices", "Intel")
|
||||
pub signer: String,
|
||||
/// Linux and HiveOS: the package to name, no installer
|
||||
pub linux_package: String,
|
||||
pub hive_package: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Table {
|
||||
pub updated: String,
|
||||
pub dry_run: bool,
|
||||
pub vendors: Vec<VendorEntry>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// The manifest's `drivers` object. Every entry is checked the way the platform entries are: https (or 127.0.0.1
|
||||
/// for a test), 64-hex sha256, a size, a version on both sides, at least one silent argument.
|
||||
pub fn parse(v: &serde_json::Value) -> Result<Table, String> {
|
||||
let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let obj = v.get("vendors").and_then(|x| x.as_object()).ok_or("drivers: no vendors object")?;
|
||||
let mut vendors = Vec::new();
|
||||
for (name, e) in obj {
|
||||
if e.is_null() {
|
||||
continue;
|
||||
}
|
||||
let vendor = name.to_ascii_lowercase();
|
||||
if !["nvidia", "amd", "intel"].contains(&vendor.as_str()) {
|
||||
return Err(format!("drivers: vendor '{name}' is not nvidia, amd or intel"));
|
||||
}
|
||||
let entry = VendorEntry {
|
||||
vendor: vendor.clone(),
|
||||
min_version: s(e, "min_version"),
|
||||
version: s(e, "version"),
|
||||
url: s(e, "url"),
|
||||
size: e.get("size").and_then(|x| x.as_u64()).unwrap_or(0),
|
||||
sha256: s(e, "sha256").to_ascii_lowercase(),
|
||||
hash_source: s(e, "hash_source"),
|
||||
referer: s(e, "referer"),
|
||||
args: e.get("args").and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_str()).map(|x| x.to_string()).collect()).unwrap_or_default(),
|
||||
reboot_codes: e.get("reboot_codes").and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_i64()).collect()).unwrap_or_default(),
|
||||
signer: s(e, "signer"),
|
||||
linux_package: s(e, "linux_package"),
|
||||
hive_package: s(e, "hive_package"),
|
||||
};
|
||||
if parse_dotted(&entry.min_version).is_none() || parse_dotted(&entry.version).is_none() {
|
||||
return Err(format!("drivers.{name}: min_version and version must be dotted numbers"));
|
||||
}
|
||||
if !entry.url.starts_with("https://") && !entry.url.starts_with("http://127.0.0.1:") {
|
||||
return Err(format!("drivers.{name}: the url is not https"));
|
||||
}
|
||||
if entry.sha256.len() != 64 || !entry.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("drivers.{name}: sha256 is not 64 hex characters"));
|
||||
}
|
||||
if entry.size == 0 {
|
||||
return Err(format!("drivers.{name}: size is missing"));
|
||||
}
|
||||
if entry.args.is_empty() || entry.args.iter().any(|a| a.contains(['"', '\'', '&', '|', ';', '`'])) {
|
||||
return Err(format!("drivers.{name}: args must be plain switches"));
|
||||
}
|
||||
if !entry.referer.is_empty() && !entry.referer.starts_with("https://") {
|
||||
return Err(format!("drivers.{name}: referer must be https"));
|
||||
}
|
||||
if entry.signer.is_empty() {
|
||||
return Err(format!("drivers.{name}: signer is missing (the Authenticode subject word)"));
|
||||
}
|
||||
vendors.push(entry);
|
||||
}
|
||||
if vendors.is_empty() {
|
||||
return Err("drivers: the vendors object is empty".into());
|
||||
}
|
||||
vendors.sort_by(|a, b| a.vendor.cmp(&b.vendor));
|
||||
Ok(Table { updated: s(v, "updated"), dry_run: v.get("dry_run").and_then(|x| x.as_bool()).unwrap_or(false), vendors })
|
||||
}
|
||||
|
||||
pub fn entry(&self, vendor: &str) -> Option<&VendorEntry> {
|
||||
self.vendors.iter().find(|e| e.vendor == vendor)
|
||||
}
|
||||
}
|
||||
|
||||
/// "32.0.101.9034" or "581.57" as numbers; None for anything else (an empty string, "3683.0 (PAL,LC)").
|
||||
pub fn parse_dotted(s: &str) -> Option<Vec<u64>> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in t.split('.') {
|
||||
out.push(p.trim().parse::<u64>().ok()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `a` is at least `b`, compared part by part (a missing trailing part reads 0).
|
||||
pub fn at_least(a: &[u64], b: &[u64]) -> bool {
|
||||
let n = a.len().max(b.len());
|
||||
for i in 0..n {
|
||||
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
|
||||
if x != y {
|
||||
return x > y;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// What the card's row shows about its driver.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct Offer {
|
||||
pub vendor: String,
|
||||
/// "missing" | "old" | "fine" | "none" (no row for this vendor in the table)
|
||||
pub status: String,
|
||||
pub installed: String,
|
||||
pub wanted: String,
|
||||
pub min_version: String,
|
||||
pub size: u64,
|
||||
pub url: String,
|
||||
pub hash_source: String,
|
||||
/// the row's sentence
|
||||
pub text: String,
|
||||
/// true when the click can install here (Windows with an entry); false with a note on macOS, Linux and HiveOS
|
||||
pub installable: bool,
|
||||
}
|
||||
|
||||
/// The platform word for the notes: "windows" | "macos" | "linux" | "hive".
|
||||
pub fn platform_word() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if Path::new("/hive").is_dir() || Path::new("/hive-config").is_dir() {
|
||||
"hive"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
|
||||
/// The offer for a card: its vendor, the version its driver reports (empty = none found) and the table.
|
||||
pub fn offer_for(vendor: &str, installed: &str, table: Option<&Table>, platform: &str) -> Option<Offer> {
|
||||
if vendor == "apple" {
|
||||
return Some(Offer { vendor: "apple".into(), status: "none".into(), installed: installed.into(), text: "Apple silicon: no driver step, macOS carries it.".into(), ..Default::default() });
|
||||
}
|
||||
let e = table.and_then(|t| t.entry(vendor))?;
|
||||
let word = match vendor {
|
||||
"nvidia" => "NVIDIA",
|
||||
"amd" => "AMD",
|
||||
"intel" => "Intel Arc",
|
||||
_ => vendor,
|
||||
};
|
||||
let mb = (e.size + 512 * 1024) / (1024 * 1024);
|
||||
let size_text = if mb >= 1024 { format!("{:.1} GB", mb as f64 / 1024.0) } else { format!("{mb} MB") };
|
||||
let have = parse_dotted(installed);
|
||||
let need = parse_dotted(&e.min_version).unwrap_or_default();
|
||||
let status = match &have {
|
||||
None => "missing",
|
||||
Some(h) if at_least(h, &need) => "fine",
|
||||
Some(_) => "old",
|
||||
};
|
||||
let base = Offer { vendor: vendor.into(), status: status.into(), installed: installed.into(), wanted: e.version.clone(), min_version: e.min_version.clone(), size: e.size, url: e.url.clone(), hash_source: e.hash_source.clone(), text: String::new(), installable: false };
|
||||
let text = match (platform, status) {
|
||||
("macos", _) => "macOS: no driver step.".to_string(),
|
||||
("hive", "fine") | ("linux", "fine") => format!("{word} driver {installed}: fine."),
|
||||
("hive", _) => format!("HiveOS: {} the driver with {} (the worker needs {} or newer).", if status == "missing" { "install" } else { "update" }, if e.hive_package.is_empty() { "hive's driver tool" } else { &e.hive_package }, e.min_version),
|
||||
("linux", _) => format!("Linux: {} the package {} (the worker needs {} or newer).", if status == "missing" { "install" } else { "update" }, if e.linux_package.is_empty() { "the vendor's driver" } else { &e.linux_package }, e.min_version),
|
||||
(_, "fine") => format!("{word} driver {installed}: fine."),
|
||||
(_, "missing") => format!("Install the {word} driver {} ({size_text}). No driver was found, so this card cannot mine yet.", e.version),
|
||||
_ => format!("Install the {word} driver {} ({size_text}). Driver {installed} is older than the {} the worker needs.", e.version, e.min_version),
|
||||
};
|
||||
Some(Offer { text, installable: platform == "windows" && status != "fine", ..base })
|
||||
}
|
||||
|
||||
/// The installer's exit code read: (restart required, the sentence).
|
||||
pub fn exit_meaning(code: i64, e: &VendorEntry) -> (bool, String) {
|
||||
if code == 0 {
|
||||
return (false, format!("{} driver {} installed.", e.vendor.to_ascii_uppercase(), e.version));
|
||||
}
|
||||
if e.reboot_codes.contains(&code) {
|
||||
return (true, format!("{} driver {} installed. Restart Windows to finish.", e.vendor.to_ascii_uppercase(), e.version));
|
||||
}
|
||||
(false, format!("the {} installer exited with code {code}.", e.vendor.to_ascii_uppercase()))
|
||||
}
|
||||
|
||||
/// The install's progress, published as `state.drivers`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct DriverState {
|
||||
/// idle | downloading | verifying | installing | done | reboot | error
|
||||
pub status: String,
|
||||
pub vendor: String,
|
||||
pub version: String,
|
||||
pub progress: f64,
|
||||
pub message: String,
|
||||
pub error: String,
|
||||
pub reboot_required: bool,
|
||||
pub dry_run: bool,
|
||||
pub started_at: f64,
|
||||
pub finished_at: f64,
|
||||
/// the table's updated stamp, for the Settings page ("drivers table of 7 October")
|
||||
pub table_updated: String,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
/// The Authenticode verdict from `Get-AuthenticodeSignature`'s two lines ("Valid" and the subject): Ok(subject) when
|
||||
/// the status is Valid and the subject carries the vendor's word.
|
||||
pub fn authenticode_verdict(text: &str, signer: &str) -> Result<String, String> {
|
||||
let mut status = String::new();
|
||||
let mut subject = String::new();
|
||||
for l in text.lines() {
|
||||
if let Some(v) = l.strip_prefix("status=") {
|
||||
status = v.trim().to_string();
|
||||
} else if let Some(v) = l.strip_prefix("subject=") {
|
||||
subject = v.trim().to_string();
|
||||
}
|
||||
}
|
||||
if status != "Valid" {
|
||||
return Err(format!("the file's signature is {}: not installed", if status.is_empty() { "unreadable".to_string() } else { status }));
|
||||
}
|
||||
if !subject.to_ascii_lowercase().contains(&signer.to_ascii_lowercase()) {
|
||||
return Err(format!("the file is signed by \"{subject}\", not {signer}: not installed"));
|
||||
}
|
||||
Ok(subject)
|
||||
}
|
||||
|
||||
/// The cards whose worker stops before a vendor's driver installer starts: every enabled card of that vendor. PC 2,
|
||||
/// 7 October 2026, 16:26Z: the Intel installer took the kernel down (bugcheck 0x3B) with the app's worker mining on the
|
||||
/// Arc B580 in a Razer Core X V2; at 19:14 BST the same card read kind=discrete on 0.3.21 (no USB4 or Thunderbolt
|
||||
/// router in its parent chain on that board), so a hold keyed on the external kind alone would have missed the card
|
||||
/// that crashed the box. The rule since 0.3.22 (the shipper's word, 19:1x BST): the vendor's cards all stop, the other
|
||||
/// vendors' cards keep mining, a card already off has nothing to stop. Each item is (key, vendor, kind, enabled).
|
||||
pub fn install_hold_keys<'a>(cards: impl IntoIterator<Item = (&'a str, &'a str, &'a str, bool)>, vendor: &str) -> Vec<String> {
|
||||
cards.into_iter().filter(|(_, v, _, enabled)| *v == vendor && *enabled).map(|(key, _, _, _)| key.to_string()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_shipped_table_accepts_the_inbox_6733_driver_for_the_arc_b580_until_an_unattended_install_exists() {
|
||||
// the project lead's rule, 7 October 2026 evening: no PC job needs a click or a UAC prompt. The one-click install asks for
|
||||
// one, so the table does not demand 9034 of an Arc on Windows' inbox 6733 (the worker mines at 11.0 MH/s on it
|
||||
// with the Intel rotate rewrite); 9034 stays the version on offer for a card with no driver at all.
|
||||
let v: serde_json::Value = serde_json::from_str(include_str!("../../../packaging/ota/drivers.json")).expect("the shipped table is JSON");
|
||||
let t = Table::parse(&v).expect("the shipped table parses");
|
||||
let intel = t.entry("intel").expect("an Intel row");
|
||||
assert_eq!(intel.min_version, "32.0.101.6733", "6733 is acceptable for the B580 until the Power Helper task installs drivers unattended");
|
||||
assert_eq!(intel.version, "32.0.101.9034");
|
||||
let o = offer_for("intel", "32.0.101.6733", Some(&t), "windows").unwrap();
|
||||
assert_eq!((o.status.as_str(), o.installable), ("fine", false), "{}", o.text);
|
||||
let o = offer_for("intel", "", Some(&t), "windows").unwrap();
|
||||
assert!(o.installable && o.text.starts_with("Install the Intel Arc driver 32.0.101.9034"), "{}", o.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_driver_install_stops_every_card_of_its_vendor_first_and_leaves_the_other_vendors_mining() {
|
||||
// PC 2, 7 October 2026: the Arc B580 in the Razer Core X V2 was mining when the Intel installer's display
|
||||
// reset took the machine down. The rule: the vendor's external cards stop, nothing else does.
|
||||
let cards = [
|
||||
("nvidia:0:NVIDIA GeForce RTX 5090", "nvidia", "discrete", true),
|
||||
("nvidia:1:NVIDIA GeForce RTX 5060 Ti", "nvidia", "external", true),
|
||||
("intel:Intel(R) Arc(TM) B580 Graphics", "intel", "external", true),
|
||||
("intel::Intel UHD 770", "other", "integrated", false),
|
||||
];
|
||||
assert_eq!(install_hold_keys(cards, "intel"), vec!["intel:Intel(R) Arc(TM) B580 Graphics".to_string()], "the Arc in the enclosure stops; the NVIDIA cards and the iGPU keep mining");
|
||||
assert_eq!(install_hold_keys(cards, "nvidia"), vec!["nvidia:0:NVIDIA GeForce RTX 5090".to_string(), "nvidia:1:NVIDIA GeForce RTX 5060 Ti".to_string()], "every NVIDIA card stops for the NVIDIA install, inside the case or not");
|
||||
assert!(install_hold_keys(cards, "amd").is_empty(), "no AMD card: nothing stops");
|
||||
// a card already off has nothing to stop; a discrete card of the vendor IS held (PC 2, 19:14 BST: the Arc in the
|
||||
// Razer Core X V2 read kind=discrete on 0.3.21, so the kind alone would have missed the card that crashed the box)
|
||||
let off = [("intel:Intel(R) Arc(TM) B580 Graphics", "intel", "external", false), ("intel:Intel(R) Arc(TM) A770 Graphics", "intel", "discrete", true)];
|
||||
assert_eq!(install_hold_keys(off, "intel"), vec!["intel:Intel(R) Arc(TM) A770 Graphics".to_string()]);
|
||||
// the iGPU carries vendor "other" on PC 2 (an AMD Radeon(TM) Graphics as amd:gfx1036 on another read): the install's
|
||||
// vendor word decides, and a card off stays untouched
|
||||
let igpu = [("amd:gfx1036", "amd", "integrated", false), ("amd:gfx1201", "amd", "discrete", true)];
|
||||
assert_eq!(install_hold_keys(igpu, "amd"), vec!["amd:gfx1201".to_string()]);
|
||||
}
|
||||
|
||||
|
||||
const TABLE: &str = r#"{"updated":"2026-10-07","vendors":{
|
||||
"nvidia":{"min_version":"570.00","version":"617.42","url":"https://us.download.nvidia.com/Windows/617.42/617.42-desktop-win10-win11-64bit-international-dch-whql.exe","size":990853168,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash_source":"https://www.nvidia.com/en-us/drivers/details/","args":["-s","-noreboot"],"reboot_codes":[1],"signer":"NVIDIA","linux_package":"nvidia-driver-570","hive_package":"nvidia-driver-update 570"},
|
||||
"amd":{"referer":"https://www.amd.com/","min_version":"32.0.32000.0","version":"26.9.2","url":"https://drivers.amd.com/drivers/whql-amd-software-adrenalin-edition-26.9.2-win10-win11-sep2026.exe","size":912345678,"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","hash_source":"https://www.amd.com/en/support/download/drivers.html","args":["-install","-silent"],"reboot_codes":[3010],"signer":"Advanced Micro Devices","linux_package":"amdgpu-install --usecase=opencl"},
|
||||
"intel":{"min_version":"32.0.101.9034","version":"32.0.101.9034","url":"https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe","size":932631144,"sha256":"72ba7eea08a0cc18650603976ff9433dfdf84d23d4cbbee662a4df9f2856ae98","hash_source":"https://www.intel.com/content/www/us/en/download/785597/","args":["-s"],"reboot_codes":[14,1014],"signer":"Intel","linux_package":"intel-opencl-icd"}}}"#;
|
||||
|
||||
fn table() -> Table {
|
||||
Table::parse(&serde_json::from_str(TABLE).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_part_by_part() {
|
||||
assert!(at_least(&parse_dotted("32.0.101.9034").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
|
||||
assert!(at_least(&parse_dotted("32.0.101.9040").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
|
||||
assert!(!at_least(&parse_dotted("32.0.101.6733").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
|
||||
assert!(at_least(&parse_dotted("581.57").unwrap(), &parse_dotted("570.00").unwrap()));
|
||||
assert!(!at_least(&parse_dotted("566.36").unwrap(), &parse_dotted("570").unwrap()));
|
||||
assert!(at_least(&parse_dotted("570").unwrap(), &parse_dotted("570.0.0").unwrap()));
|
||||
assert_eq!(parse_dotted("3683.0 (PAL,LC)"), None);
|
||||
assert_eq!(parse_dotted(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_parses_and_bad_rows_are_refused() {
|
||||
let t = table();
|
||||
assert_eq!(t.vendors.len(), 3);
|
||||
assert_eq!(t.entry("intel").unwrap().reboot_codes, vec![14, 1014]);
|
||||
assert_eq!(t.entry("nvidia").unwrap().args, vec!["-s", "-noreboot"]);
|
||||
assert!(!t.dry_run);
|
||||
let bad = |patch: &str, what: &str| {
|
||||
let txt = TABLE.replacen(patch, what, 1);
|
||||
let e = Table::parse(&serde_json::from_str(&txt).unwrap()).unwrap_err();
|
||||
e
|
||||
};
|
||||
assert!(bad("https://downloadmirror", "http://downloadmirror").contains("https"));
|
||||
assert!(bad("\"size\":932631144", "\"size\":0").contains("size"));
|
||||
assert!(bad("\"args\":[\"-s\"]", "\"args\":[\"-s; calc\"]").contains("args"));
|
||||
assert!(bad("\"signer\":\"Intel\"", "\"signer\":\"\"").contains("signer"));
|
||||
assert!(bad("\"args\":[\"-install\",\"-silent\"]", "\"referer\":\"http://x\",\"args\":[\"-install\",\"-silent\"]").contains("referer"));
|
||||
assert_eq!(t.entry("amd").unwrap().referer, "https://www.amd.com/");
|
||||
assert!(bad("\"min_version\":\"570.00\"", "\"min_version\":\"latest\"").contains("dotted"));
|
||||
assert!(Table::parse(&serde_json::json!({"vendors": {"apple": {}}})).unwrap_err().contains("vendor"));
|
||||
assert!(Table::parse(&serde_json::json!({"vendors": {}})).unwrap_err().contains("empty"));
|
||||
assert!(Table::parse(&serde_json::json!({"vendors": {"intel": null}})).unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
/// The offers per tier: a missing driver, an old one, a fine one, Apple, Linux and HiveOS.
|
||||
#[test]
|
||||
fn offers_name_the_version_the_size_and_the_reason() {
|
||||
let t = table();
|
||||
let o = offer_for("intel", "", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "missing");
|
||||
assert!(o.installable);
|
||||
assert_eq!(o.text, "Install the Intel Arc driver 32.0.101.9034 (889 MB). No driver was found, so this card cannot mine yet.");
|
||||
let o = offer_for("intel", "32.0.101.6733", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "old");
|
||||
assert_eq!(o.text, "Install the Intel Arc driver 32.0.101.9034 (889 MB). Driver 32.0.101.6733 is older than the 32.0.101.9034 the worker needs.");
|
||||
let o = offer_for("intel", "32.0.101.9034", Some(&t), "windows").unwrap();
|
||||
assert_eq!((o.status.as_str(), o.installable, o.text.as_str()), ("fine", false, "Intel Arc driver 32.0.101.9034: fine."));
|
||||
let o = offer_for("nvidia", "617.42", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "fine");
|
||||
let o = offer_for("nvidia", "566.36", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.text, "Install the NVIDIA driver 617.42 (945 MB). Driver 566.36 is older than the 570.00 the worker needs.");
|
||||
let o = offer_for("amd", "32.0.21042.62", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "old");
|
||||
assert!(o.text.starts_with("Install the AMD driver 26.9.2 (870 MB)."));
|
||||
// Apple: no step; Linux and HiveOS: the package, nothing installable
|
||||
let o = offer_for("apple", "", Some(&t), "macos").unwrap();
|
||||
assert_eq!((o.status.as_str(), o.installable), ("none", false));
|
||||
assert!(o.text.contains("no driver step"));
|
||||
let o = offer_for("nvidia", "", Some(&t), "linux").unwrap();
|
||||
assert_eq!(o.text, "Linux: install the package nvidia-driver-570 (the worker needs 570.00 or newer).");
|
||||
assert!(!o.installable);
|
||||
let o = offer_for("nvidia", "566.36", Some(&t), "hive").unwrap();
|
||||
assert_eq!(o.text, "HiveOS: update the driver with nvidia-driver-update 570 (the worker needs 570.00 or newer).");
|
||||
let o = offer_for("intel", "", Some(&t), "macos").unwrap();
|
||||
assert_eq!(o.text, "macOS: no driver step.");
|
||||
// no table, or a vendor the table lacks: no offer, the row says nothing
|
||||
assert!(offer_for("nvidia", "", None, "windows").is_none());
|
||||
assert!(offer_for("other", "", Some(&t), "windows").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_and_signatures_read_as_the_vendor_means_them() {
|
||||
let t = table();
|
||||
let intel = t.entry("intel").unwrap();
|
||||
assert_eq!(exit_meaning(0, intel), (false, "INTEL driver 32.0.101.9034 installed.".into()));
|
||||
assert_eq!(exit_meaning(1014, intel).0, true);
|
||||
assert_eq!(exit_meaning(14, intel).1, "INTEL driver 32.0.101.9034 installed. Restart Windows to finish.");
|
||||
assert_eq!(exit_meaning(1007, intel), (false, "the INTEL installer exited with code 1007.".into()));
|
||||
assert!(authenticode_verdict("status=Valid\nsubject=CN=Intel Corporation, O=Intel Corporation, S=California, C=US\n", "Intel").is_ok());
|
||||
assert!(authenticode_verdict("status=NotSigned\nsubject=\n", "Intel").unwrap_err().contains("NotSigned"));
|
||||
assert!(authenticode_verdict("status=Valid\nsubject=CN=Someone Else\n", "Intel").unwrap_err().contains("Someone Else"));
|
||||
assert!(authenticode_verdict("", "Intel").unwrap_err().contains("unreadable"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -25,6 +25,31 @@ pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50];
|
|||
pub const CLOCK_STEPS_PCT: [u32; 7] = [100, 90, 80, 70, 60, 50, 45];
|
||||
/// A card's clock floor when the vendor reports none: this share of its maximum core clock.
|
||||
pub const CLOCK_FLOOR_PCT: u32 = 45;
|
||||
/// The measured efficient point per card (docs/bench-log.md: the 5090 at 308 W for 122 MH/s, the 9070 XT at 199 W for
|
||||
/// 18 MH/s), the tuner's ceiling. Rule (main, 7 October 2026, after PC 2 dropped nine minutes after the tuner asked its
|
||||
/// 5090 for 575 W with proving on the same card): the tuner never requests more than the card's measured efficient point
|
||||
/// unless Power control is on and the user raised the cap above the default; a card not in this table is held at the
|
||||
/// cap it runs at. A point below the vendor's minimum limit clamps up to that minimum (the 5090 floors at 400 W).
|
||||
pub const EFFICIENT_W: &[(&str, f64)] = &[("RTX 5090", 308.0), ("RX 9070 XT", 199.0)];
|
||||
/// The app's cap when the user chose none (engine.rs requested_watts); a cap above it is one the user raised.
|
||||
pub const DEFAULT_CAP_PCT: u32 = 80;
|
||||
|
||||
/// The measured efficient watts of a card by its name, when the table has it.
|
||||
pub fn efficient_watts(name: &str) -> Option<f64> {
|
||||
let n = name.to_ascii_uppercase();
|
||||
EFFICIENT_W.iter().find(|(k, _)| n.contains(&k.to_ascii_uppercase())).map(|(_, w)| *w)
|
||||
}
|
||||
|
||||
/// The tuner's power ceiling in watts for a card: its measured efficient point, or the cap it runs at when the table has
|
||||
/// no entry; the user's own cap instead when Power control is on and that cap is above the default (they raised it).
|
||||
pub fn power_ceiling(limits: &Limits, name: &str, before: Point, power_control: bool) -> f64 {
|
||||
let cap = Limits { power_ceiling_w: 0.0, ..limits.clone() }.watts_for(if before.power_pct == 0 { DEFAULT_CAP_PCT } else { before.power_pct });
|
||||
let raised = power_control && before.power_pct > DEFAULT_CAP_PCT;
|
||||
match efficient_watts(name) {
|
||||
Some(e) if !raised => e.min(cap),
|
||||
_ => cap,
|
||||
}
|
||||
}
|
||||
/// A point may lose this much rate against the fastest point and still win on MH per watt (the manifest can change it).
|
||||
pub const RATE_TOLERANCE_PCT: f64 = 1.0;
|
||||
/// A step whose hottest GPU reading reaches this is marked hot and cannot win (the engine aborts at 90).
|
||||
|
|
@ -55,9 +80,20 @@ pub struct Limits {
|
|||
/// clocks.max.mem); 0 = no memory knob (AMD through ADLX on RDNA 4 exposes none)
|
||||
pub mem_default_mhz: u32,
|
||||
pub mem_max_mhz: u32,
|
||||
/// the tuner's ceiling (power_ceiling), 0 = none: no step asks for more watts than this
|
||||
pub power_ceiling_w: f64,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// The ceiling as the vendor allows it: never below the card's minimum limit; 0 when there is none.
|
||||
pub fn ceiling(&self) -> f64 {
|
||||
if self.power_ceiling_w <= 0.0 { 0.0 } else { self.power_ceiling_w.max(self.power_min_w) }
|
||||
}
|
||||
/// The percent of the default that the ceiling is, for the first step of the power ladder.
|
||||
pub fn ceiling_pct(&self) -> u32 {
|
||||
let c = self.ceiling();
|
||||
if c <= 0.0 || self.power_default_w <= 0.0 { 100 } else { (c / self.power_default_w * 100.0).round().clamp(1.0, 100.0) as u32 }
|
||||
}
|
||||
pub fn clock_floor(&self) -> u32 {
|
||||
if self.clock_min_mhz > 0 {
|
||||
self.clock_min_mhz
|
||||
|
|
@ -88,6 +124,10 @@ impl Limits {
|
|||
if self.power_max_w > 0.0 {
|
||||
w = w.min(self.power_max_w);
|
||||
}
|
||||
let c = self.ceiling();
|
||||
if c > 0.0 {
|
||||
w = w.min(c);
|
||||
}
|
||||
w.round()
|
||||
}
|
||||
}
|
||||
|
|
@ -276,7 +316,9 @@ impl Plan {
|
|||
pub fn full(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan {
|
||||
let mut power = Vec::new();
|
||||
if limits.power_default_w > 0.0 {
|
||||
for pct in POWER_STEPS_PCT {
|
||||
// the ceiling first (its own percent), then the ladder; every step above it clamps to it and is dropped as a duplicate
|
||||
let top = if limits.ceiling() > 0.0 { vec![limits.ceiling_pct()] } else { Vec::new() };
|
||||
for pct in top.into_iter().chain(POWER_STEPS_PCT) {
|
||||
let w = limits.watts_for(pct);
|
||||
if power.last().map(|s: &Step| (s.watts - w).abs() < 0.5).unwrap_or(false) {
|
||||
continue;
|
||||
|
|
@ -906,6 +948,115 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
|
|||
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
|
||||
}
|
||||
|
||||
/// HH:MM UTC of a unix time (the day is not shown: "since 18:39 UTC").
|
||||
pub fn hhmm_utc(unix: f64) -> String {
|
||||
let s = unix.max(0.0) as u64 % 86_400;
|
||||
format!("{:02}:{:02}", s / 3600, (s % 3600) / 60)
|
||||
}
|
||||
|
||||
/// Horizon polish Q83: the one sentence every surface shows while finality is paused. The cause is the node's own
|
||||
/// (`reason`, with who holds it) when it carries one, else the plain two-thirds line; never the word "final".
|
||||
pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> String {
|
||||
let cause = if reason.trim().is_empty() {
|
||||
"under two thirds of the weight is signing".to_string()
|
||||
} else if held_by.trim().is_empty() {
|
||||
reason.trim().to_string()
|
||||
} else {
|
||||
format!("{} (held by {})", reason.trim(), held_by.trim())
|
||||
};
|
||||
format!("Finality paused since {} UTC: {cause}", hhmm_utc(since_unix))
|
||||
}
|
||||
|
||||
/// The node's structured finality fields on igneum_getProvingStatus (the node lane, 0.3.16): "finalityReason"
|
||||
/// (empty when live), "finalityProvisional", "heldBy" {tableIndex, stayersShareBps, expiresDaa} or null,
|
||||
/// "pausedSinceMs" or null. Returns (reason, held-by words, provisional, paused_since unix seconds).
|
||||
pub struct FinalityStatus {
|
||||
/// the node's own word on whether finality is live (b2e21447: finalityActive); None on an older node
|
||||
pub active: Option<bool>,
|
||||
pub reason: String,
|
||||
pub held_by: String,
|
||||
pub provisional: bool,
|
||||
pub paused_since: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn parse_finality_status(v: &serde_json::Value) -> Option<FinalityStatus> {
|
||||
// the node's words begin "paused: ..." (b2e21447); the app's sentence already says "Finality paused since", so
|
||||
// that prefix goes; a reason that already names who holds it ("held by weight table 7, ...") is not repeated
|
||||
let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().trim_start_matches("paused:").trim().to_string();
|
||||
let active = v.get("finalityActive").and_then(|b| b.as_bool());
|
||||
let provisional = v.get("finalityProvisional").and_then(|b| b.as_bool()).unwrap_or(false);
|
||||
let held_by = match v.get("heldBy") {
|
||||
Some(h) if h.is_object() => {
|
||||
let idx = h.get("tableIndex").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let bps = h.get("stayersShareBps").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let exp = h.get("expiresDaa").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
format!("weight table {idx}, {}.{:02}% still signing, expires at DAA {exp}", bps / 100, bps % 100)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let held_by = if reason.contains("held by") { String::new() } else { held_by };
|
||||
let paused_since = v.get("pausedSinceMs").and_then(|x| x.as_u64()).filter(|ms| *ms > 0).map(|ms| ms as f64 / 1000.0);
|
||||
Some(FinalityStatus { active, reason, held_by, provisional, paused_since })
|
||||
}
|
||||
|
||||
/// The node's pause line, when it carries one: `... finality_reason=<words or "quoted words"> held_by=<id>`.
|
||||
/// Returns (reason, held_by); None when the line is not one.
|
||||
pub fn parse_finality_line(text: &str) -> Option<(String, String)> {
|
||||
let i = text.find("finality_reason=")?;
|
||||
let rest = &text[i + "finality_reason=".len()..];
|
||||
let (reason, after) = if let Some(q) = rest.strip_prefix('"') {
|
||||
let end = q.find('"')?;
|
||||
(q[..end].to_string(), &q[end + 1..])
|
||||
} else {
|
||||
let end = rest.find(' ').unwrap_or(rest.len());
|
||||
(rest[..end].replace('_', " "), &rest[end..])
|
||||
};
|
||||
let held_by = after.find("held_by=").map(|j| after[j + 8..].split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();
|
||||
Some((reason, held_by))
|
||||
}
|
||||
|
||||
/// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under
|
||||
/// `max_age_s` old (a stale reading is not a draw).
|
||||
pub fn fleet_watts<'a>(cards: impl Iterator<Item = (&'a str, f64, f64)>, now: f64, max_age_s: f64) -> f64 {
|
||||
cards.filter(|(state, w, at)| *state == "mining" && *w > 0.0 && now - *at < max_age_s).map(|(_, w, _)| w).sum()
|
||||
}
|
||||
|
||||
/// A hex quantity (0x-prefixed, as eth_getBalance answers) as a decimal string; None when it is not one.
|
||||
pub fn wei_from_hex(hex: &str) -> Option<String> {
|
||||
let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim());
|
||||
if h.is_empty() {
|
||||
return Some("0".into());
|
||||
}
|
||||
u128::from_str_radix(h, 16).ok().map(|v| v.to_string())
|
||||
}
|
||||
|
||||
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
|
||||
pub fn goal_for(card_goal: &str, global: &str) -> Goal {
|
||||
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
|
||||
}
|
||||
|
||||
/// (D) May a step be SET on this card? Measure-only (no control: Apple, NVIDIA without Power control, an AMD card
|
||||
/// whose probe gave no tune line) sets nothing: the run notices the missing acknowledgement and measures. An AMD
|
||||
/// card also needs its ADLX ordinal. 6 October 2026, PC 1 17:57Z: a measure-only 9070 XT was sent a set, refused.
|
||||
pub fn may_set(vendor: &str, tune_control: bool, amd_ordinal: i64) -> bool {
|
||||
match vendor {
|
||||
"nvidia" => tune_control,
|
||||
"amd" => tune_control && amd_ordinal >= 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// (E) The row's words while a back-off from a failed tune holds: when the retry comes and why it stopped.
|
||||
pub fn retry_note(remaining_s: u64, last_note: &str) -> String {
|
||||
let mins = (remaining_s + 59) / 60;
|
||||
let why = last_note.trim_start_matches("tuning stopped: ").trim_start_matches("tuning: ").trim();
|
||||
if why.is_empty() {
|
||||
format!("tuning: retry in {mins} min")
|
||||
} else {
|
||||
format!("tuning: retry in {mins} min ({why})")
|
||||
}
|
||||
}
|
||||
|
||||
/// The row's line for a baseline (measure-only) result: the card was measured as it runs, nothing was set.
|
||||
/// (6 October 2026: PC 1's rows read "Tuned: 114.2 MH/s at 305 W" for a baseline, which is not a tune.)
|
||||
pub fn measured_line(mhs: f64, watts: f64, eff: f64) -> String {
|
||||
|
|
@ -942,7 +1093,71 @@ mod tests {
|
|||
/// PC 1's RTX 5090 (nvidia-smi, 4 and 5 October 2026): default 575 W, min 400 W, max 600 W; clocks.max.gr is
|
||||
/// read at the first tune (3,090 MHz is the shape used here, not a measurement).
|
||||
fn l5090() -> Limits {
|
||||
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001 }
|
||||
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001, power_ceiling_w: 0.0 }
|
||||
}
|
||||
|
||||
fn with_ceiling(name: &str, before: Point, power_control: bool) -> Limits {
|
||||
let mut l = l5090();
|
||||
l.power_ceiling_w = power_ceiling(&l, name, before, power_control);
|
||||
l
|
||||
}
|
||||
fn max_watts(plan: &Plan) -> f64 {
|
||||
let mut rows = Vec::new();
|
||||
let mut top: f64 = 0.0;
|
||||
while let Some(s) = plan.next(&rows) {
|
||||
top = top.max(s.watts);
|
||||
rows.push(row_at(s.point, s.watts, 100.0));
|
||||
if rows.len() > 40 { break; }
|
||||
}
|
||||
top
|
||||
}
|
||||
|
||||
/// PC 2, 7 October 2026: the tuner asked the 5090 for 100% (575 W) while proving ran on the same card; the PC dropped
|
||||
/// nine minutes later. The known-failed shape first: without a ceiling the full plan opens at 575 W.
|
||||
#[test]
|
||||
fn without_a_ceiling_the_full_plan_asks_the_5090_for_575_w() {
|
||||
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0);
|
||||
assert_eq!(plan.next(&[]).unwrap().watts, 575.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tuner_never_asks_above_the_measured_efficient_point() {
|
||||
// the 5090's measured point is 308 W; the vendor's minimum is 400 W, so the ceiling clamps up to 400
|
||||
let l = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, false);
|
||||
assert_eq!(l.power_ceiling_w, 308.0);
|
||||
assert_eq!(l.ceiling(), 400.0);
|
||||
let plan = Plan::full(&l, Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0);
|
||||
let first = plan.next(&[]).unwrap();
|
||||
assert_eq!((first.watts, first.point.power_pct, first.kind), (400.0, 70, Kind::Power), "one power step at the ceiling, with its own percent");
|
||||
assert_eq!(plan.len(), 1 + 6, "one power step (every ladder step clamps to 400 W) and the six clock steps");
|
||||
assert!(max_watts(&plan) <= 400.0, "no step above the ceiling");
|
||||
// the confirm plan and the climb honour it too: a fleet prior at 100% is clamped
|
||||
let prior = Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 };
|
||||
assert!(max_watts(&Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0)) <= 400.0);
|
||||
assert!(max_watts(&Plan::climb(&l, prior, Goal::Efficiency, 1.0)) <= 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_control_on_with_a_cap_the_user_raised_lifts_the_ceiling_to_that_cap() {
|
||||
let l = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, true);
|
||||
assert_eq!(l.ceiling(), 518.0, "the user's 90% (518 W), never the vendor's 575 W");
|
||||
assert!(max_watts(&Plan::full(&l, Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, 1.0)) <= 518.0);
|
||||
// Power control on at the default cap is not a raise: the measured point holds
|
||||
let d = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: DEFAULT_CAP_PCT, mem_mhz: 0 }, true);
|
||||
assert_eq!(d.ceiling(), 400.0);
|
||||
// a raised cap with Power control OFF does not count
|
||||
let off = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, false);
|
||||
assert_eq!(off.ceiling(), 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_not_in_the_table_is_held_at_the_cap_it_runs_at() {
|
||||
let l = with_ceiling("NVIDIA GeForce RTX 4070", Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, false);
|
||||
assert_eq!(l.ceiling(), 403.0, "70% of 575 W, the card's own cap");
|
||||
let zero = with_ceiling("NVIDIA GeForce RTX 4070", Point::default(), false);
|
||||
assert_eq!(zero.ceiling(), 460.0, "no chosen cap: the app's default 80%");
|
||||
assert_eq!(efficient_watts("AMD Radeon RX 9070 XT"), Some(199.0));
|
||||
assert_eq!(efficient_watts("gfx1036"), None);
|
||||
}
|
||||
|
||||
fn row_at(p: Point, watts: f64, mhs: f64) -> Row {
|
||||
|
|
@ -1007,6 +1222,74 @@ mod tests {
|
|||
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fleet_draw_sums_mining_cards_with_a_fresh_reading_and_the_balance_reads_in_wei() {
|
||||
let now = 1_791_300_000.0;
|
||||
let cards = vec![("mining", 226.8, now - 5.0), ("mining", 75.6, now - 10.0), ("mining", 201.0, now - 120.0), ("off", 30.0, now - 1.0), ("mining", 0.0, now)];
|
||||
let w = fleet_watts(cards.iter().map(|(s, w, at)| (*s, *w, *at)), now, 60.0);
|
||||
assert!((w - 302.4).abs() < 1e-9, "the stale 9070 XT reading and the card that is off do not count: {w}");
|
||||
assert!((pounds_per_day(302.4, 28.5) - 2.068416).abs() < 1e-6);
|
||||
assert_eq!(wei_from_hex("0x1a"), Some("26".into()));
|
||||
assert_eq!(wei_from_hex("0x0"), Some("0".into()));
|
||||
assert_eq!(wei_from_hex("0x"), Some("0".into()));
|
||||
assert_eq!(wei_from_hex("0xde0b6b3a7640000"), Some("1000000000000000000".into()), "one IGN");
|
||||
assert_eq!(wei_from_hex("soon"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_finality_pause_line_names_the_time_and_the_cause_and_never_says_final() {
|
||||
// 6 October 2026 18:39:00Z
|
||||
let since = 1_791_311_940.0;
|
||||
assert_eq!(hhmm_utc(since), "18:39");
|
||||
let plain = finality_paused_line(since, "", "");
|
||||
assert_eq!(plain, "Finality paused since 18:39 UTC: under two thirds of the weight is signing");
|
||||
assert!(!plain.to_ascii_lowercase().contains("final "), "no 'final' word while paused: {plain}");
|
||||
assert_eq!(finality_paused_line(since, "a checkpoint vote is split", "ae432dc7"), "Finality paused since 18:39 UTC: a checkpoint vote is split (held by ae432dc7)");
|
||||
assert_eq!(parse_finality_line("1791311940 finality paused finality_reason=\"vote split at index 412\" held_by=ae432dc7"), Some(("vote split at index 412".into(), "ae432dc7".into())));
|
||||
assert_eq!(parse_finality_line("x finality_reason=under_two_thirds"), Some(("under two thirds".into(), String::new())));
|
||||
assert_eq!(parse_finality_line("status: accepted 3 blocks"), None);
|
||||
// the structured carrier on igneum_getProvingStatus (the node lane's field names)
|
||||
// the node's own words (b2e21447): "paused: under two thirds ... ({p}%; the table frozen at lock {j} has {q}% signing)"
|
||||
let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityActive":false,"finalityReason":"paused: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)","finalityProvisional":false,"heldBy":null,"pausedSinceMs":1791311940000}"#).unwrap();
|
||||
let f = parse_finality_status(&v).unwrap();
|
||||
assert_eq!((f.active, f.provisional, f.paused_since), (Some(false), false, Some(1_791_311_940.0)));
|
||||
assert_eq!(finality_paused_line(f.paused_since.unwrap(), &f.reason, &f.held_by), "Finality paused since 18:39 UTC: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)");
|
||||
// the held-by form: the reason names the table itself, so heldBy is not repeated
|
||||
let v2: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: held by weight table 7, 61% of it signing, expires at DAA 205000","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap();
|
||||
let g = parse_finality_status(&v2).unwrap();
|
||||
assert!(g.provisional && g.held_by.is_empty());
|
||||
assert_eq!(finality_paused_line(g.paused_since.unwrap(), &g.reason, &g.held_by), "Finality paused since 18:39 UTC: held by weight table 7, 61% of it signing, expires at DAA 205000");
|
||||
// a reason without the table words keeps the heldBy suffix
|
||||
let v3: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: no checkpoint determined above the latest lock","heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000}}"#).unwrap();
|
||||
assert_eq!(parse_finality_status(&v3).unwrap().held_by, "weight table 7, 61.50% still signing, expires at DAA 205000");
|
||||
// live finality: an empty reason, null heldBy, null pausedSinceMs
|
||||
let live: serde_json::Value = serde_json::from_str(r#"{"finalityReason":"","finalityProvisional":false,"heldBy":null,"pausedSinceMs":null}"#).unwrap();
|
||||
let l = parse_finality_status(&live).unwrap();
|
||||
assert!(l.reason.is_empty() && l.held_by.is_empty() && !l.provisional && l.paused_since.is_none() && l.active.is_none());
|
||||
// a node before 0.3.16 carries none of it
|
||||
assert!(parse_finality_status(&serde_json::json!({"v1":{"active":true}})).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
|
||||
assert_eq!(goal_for("", "balanced"), Goal::Balanced);
|
||||
assert_eq!(goal_for("rate", "balanced"), Goal::MaxRate);
|
||||
assert_eq!(goal_for("efficiency", "rate"), Goal::Efficiency);
|
||||
assert_eq!(goal_for(" ", "efficiency"), Goal::Efficiency);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_measure_only_card_is_never_set_and_a_back_off_says_when_the_retry_comes() {
|
||||
assert!(!may_set("amd", false, 1), "the probe gave no tune line: measure only");
|
||||
assert!(may_set("amd", true, 1));
|
||||
assert!(!may_set("amd", true, -1), "no ADLX ordinal");
|
||||
assert!(!may_set("nvidia", false, -1), "Power control off: measure only");
|
||||
assert!(may_set("nvidia", true, -1));
|
||||
assert!(!may_set("apple", true, -1));
|
||||
assert_eq!(retry_note(3599, "tuning stopped: the card refused the setting (see the log)"), "tuning: retry in 60 min (the card refused the setting (see the log))");
|
||||
assert_eq!(retry_note(30, ""), "tuning: retry in 1 min");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_result_reads_measured_and_a_tune_reads_tuned() {
|
||||
assert_eq!(result_line(PlanKind::Baseline, 114.2, 305.0, 0.3744), "Measured: 114.2 MH/s at 305 W (0.374 MH/W)");
|
||||
|
|
@ -1081,7 +1364,7 @@ mod tests {
|
|||
fn a_fault_during_a_step_reverts_it_and_the_run_goes_on() {
|
||||
let t0 = Instant::now();
|
||||
let timing = Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(4), apply: Duration::from_secs(30) };
|
||||
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0 };
|
||||
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0, power_ceiling_w: 0.0 };
|
||||
let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
|
||||
let mut run = Run::new(0, "0", "c", plan, 240.0, false, timing, t0);
|
||||
let mut t = t0;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
164
app/igneum-app/src/execrpc.rs
Normal file
164
app/igneum-app/src/execrpc.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! The one path to the node's execution-layer JSON-RPC (ledger N7, 7 October 2026, main's rule for 0.3.19).
|
||||
//!
|
||||
//! A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes
|
||||
//! the record vector is asked while its exec follower holds no record: `rpc.rs` indexes `records[0]` (or slices
|
||||
//! `records[1..=0]`) on an empty vector, the panic hook exits the process, and the app restarts it. PC 1 crash-looped on
|
||||
//! two callers in one night (the clock sample's eth_getBlockByNumber, then the prover's igneum_getAssignedShards after the
|
||||
//! node read synced seconds before a slow follower loaded). The node-side fix ships with the 0.3.20 node; a 0.3.19 app on a
|
||||
//! 0.3.17 node must be safe by itself, so every exec RPC call the app makes goes through [`call`]: a method in
|
||||
//! [`SAFE_ON_EMPTY`] goes out at once; any other waits until igneum_getExecStatus reports an executed tip. The unit test
|
||||
//! below enumerates the callers: no other file may build an exec JSON-RPC request, and every method name in the tree must
|
||||
//! be classified here, so a new caller or method cannot bypass the gate.
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Methods that index nothing on an empty exec state (read from the 0.3.17 node's rpc.rs): safe at any time.
|
||||
pub const SAFE_ON_EMPTY: &[&str] = &[
|
||||
"eth_chainId", "eth_blockNumber", "eth_syncing", "igneum_getExecStatus", "igneum_getProvingStatus", "igneum_getNodeInfo",
|
||||
// the engine's balance read (0.3.21): an account lookup at the latest state, nothing indexed by block number
|
||||
"eth_getBalance",
|
||||
];
|
||||
|
||||
/// Methods the app sends that resolve a block number, index or slice the record vector, or simulate at a block: held until
|
||||
/// the follower holds a record. Every method literal outside this module must be in one of the two lists.
|
||||
pub const GATED: &[&str] = &[
|
||||
"eth_getBlockByNumber", "igneum_getAssignedShards", "igneum_getProofRecords", "igneum_getSegmentRecords", "igneum_getSegmentStatement",
|
||||
"igneum_getProofBytes", "igneum_getSegmentProofBytes", "igneum_exportSegments", "igneum_submitProofRecord", "igneum_submitSegmentRecord",
|
||||
"igneum_getFinalityWeights",
|
||||
// 0.3.21's live page reads recent blocks by number through the same path (src/live.rs); held like the rest
|
||||
"igneum_getRecentBlocks",
|
||||
];
|
||||
|
||||
/// One JSON-RPC POST to 127.0.0.1:<evm_port> through curl (the engine carries no HTTP client); the body goes through a file
|
||||
/// so a large export request is not an argument. The reply's `result` (null allowed), or the error's message.
|
||||
fn post(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-rpc-{}-{}-{}.json", std::process::id(), method, crate::platform::unix_now_f() as u64));
|
||||
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
||||
let url = format!("http://127.0.0.1:{evm_port}");
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().max(1).to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &format!("@{}", tmp.display())]),
|
||||
None,
|
||||
timeout + Duration::from_secs(2),
|
||||
);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
|
||||
// an empty or unparsable body is no answer (a stopped node's socket still accepts the connection and curl
|
||||
// returns nothing inside its own limit; the probe must read that as silence, 7 October 2026)
|
||||
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: the node's RPC did not answer ({e})"))?;
|
||||
if let Some(err) = v.get("error") {
|
||||
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
|
||||
/// unreachable node reads false: the gated call waits rather than asks.
|
||||
pub fn has_record(evm_port: u16) -> bool {
|
||||
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||
Ok(r) => status_has_record(&r),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reading of an igneum_getExecStatus result: a record is held when executedTipHash is a non-empty string.
|
||||
pub fn status_has_record(result: &Value) -> bool {
|
||||
result.get("executedTipHash").and_then(|h| h.as_str()).map(|h| !h.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The engine's readiness probe (every 5 s): did the node's RPC answer at all, and does the follower hold a record.
|
||||
/// The first is the node watchdog's sign of life; the second, with `synced`, is the workers' start gate.
|
||||
pub fn probe(evm_port: u16) -> (bool, bool) {
|
||||
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||
Ok(r) => (true, status_has_record(&r)),
|
||||
Err(e) => (!e.contains("did not answer"), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate: a safe method goes out; a gated one waits for a record; an unclassified method is refused (add it to a list).
|
||||
pub fn call(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
if SAFE_ON_EMPTY.contains(&method) {
|
||||
return post(evm_port, method, params, timeout);
|
||||
}
|
||||
if !GATED.contains(&method) {
|
||||
return Err(format!("{method}: not classified in execrpc (safe on an empty state, or gated); add it before calling"));
|
||||
}
|
||||
if !has_record(evm_port) {
|
||||
return Err(format!("{method}: the node's execution layer holds no record yet"));
|
||||
}
|
||||
post(evm_port, method, params, timeout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn status_reading() {
|
||||
assert!(status_has_record(&json!({"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec"})));
|
||||
assert!(!status_has_record(&json!({"executedTip":"0x0","executedTipHash":null})));
|
||||
assert!(!status_has_record(&json!({})));
|
||||
assert!(!status_has_record(&json!({"executedTipHash":""})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_are_disjoint_and_sorted_enough() {
|
||||
for m in GATED {
|
||||
assert!(!SAFE_ON_EMPTY.contains(m), "{m} in both lists");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ledger N7: every exec JSON-RPC request the app builds goes through this module, and every exec method name in the
|
||||
/// tree is classified here. A new direct caller (a file that builds a "jsonrpc" POST) or an unclassified method fails.
|
||||
#[test]
|
||||
fn every_caller_goes_through_the_gate() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
// every eth_* or igneum_* identifier on a line (a hand scanner: no regex crate in this binary)
|
||||
fn methods_in(line: &str) -> Vec<String> {
|
||||
// ASCII-only scan over char boundaries: a token of [A-Za-z0-9_] that starts with eth_ or igneum_
|
||||
let mut out = Vec::new();
|
||||
let mut token = String::new();
|
||||
let mut flush = |t: &mut String| {
|
||||
if t.starts_with("eth_") || t.starts_with("igneum_") { out.push(t.clone()); }
|
||||
t.clear();
|
||||
};
|
||||
for ch in line.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' { token.push(ch); } else { flush(&mut token); }
|
||||
}
|
||||
flush(&mut token);
|
||||
out
|
||||
}
|
||||
let mut offenders = Vec::new();
|
||||
let mut unclassified = Vec::new();
|
||||
for entry in std::fs::read_dir(&src).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") { continue; }
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// a JSON-RPC REQUEST names a method next to "jsonrpc" (replies and test fixtures carry "result" or "error");
|
||||
// only this module may build one
|
||||
if name != "execrpc.rs" {
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let l = line.replace('\\', "");
|
||||
if l.contains("\"jsonrpc\"") && l.contains("\"method\"") && !l.contains("\"result\"") && !l.contains("\"error\"") {
|
||||
offenders.push(format!("{name}:{}", i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
if line.trim_start().starts_with("//") { continue; }
|
||||
for m in methods_in(line) {
|
||||
let m = m.as_str();
|
||||
// identifiers that are not RPC methods: crate and file names (igneum_app, igneum_miner, ...) carry no camel-case method part
|
||||
let looks_like_method = m.contains("_get") || m.contains("_submit") || m.contains("_export") || m.contains("_estimate") || m.contains("_send") || m == "eth_chainId" || m == "eth_blockNumber" || m == "eth_syncing";
|
||||
if looks_like_method && !SAFE_ON_EMPTY.contains(&m) && !GATED.contains(&m) {
|
||||
unclassified.push(format!("{name}:{}: {m}", i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(offenders.is_empty(), "exec JSON-RPC built outside execrpc.rs: {offenders:?}");
|
||||
assert!(unclassified.is_empty(), "exec methods not classified in execrpc.rs: {unclassified:?}");
|
||||
}
|
||||
}
|
||||
242
app/igneum-app/src/extnode.rs
Normal file
242
app/igneum-app/src/extnode.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
//! Another node on this machine (publish 2's read-back, 6 October 2026): the hand node on the Mac held 26610/26611,
|
||||
//! the app's default RPC and p2p ports, the app read its state as its own and the digest field read empty. Now the
|
||||
//! collision is loud on every surface and checked: the other node's chain id (eth_chainId on the EVM port) and its
|
||||
//! consensus overrides (`igneum_getNodeInfo`, the node lane, 7 October 2026; "method not found" on an older node) against the
|
||||
//! override file the app would start its own node with. A match: "Another node holds port N on this machine; using
|
||||
//! it". A mismatch: "Node not started: port N is taken" and the app does not read that node. Unknown (an older node
|
||||
//! that cannot answer): used, and said so. The same RPC gives the digest, so api/state carries it for an external node
|
||||
//! too, read every 30 s instead of parsed from a stdout the app does not own.
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What the other node answered: None where it could not answer.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Check {
|
||||
pub chain_id: Option<u64>,
|
||||
pub digest: Option<String>,
|
||||
pub network: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name)
|
||||
pub overrides: Option<Value>,
|
||||
/// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block)
|
||||
pub pow_engine: Option<String>,
|
||||
/// the network's compiled merge depth in blue score (`blockrate.mergeDepth`, the node lane's addition of 7 October 2026);
|
||||
/// None on a node without the object, and the app assumes 3,600 (1 bps)
|
||||
pub merge_depth: Option<u64>,
|
||||
}
|
||||
|
||||
/// The decision for the port-collision path.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Decision {
|
||||
pub use_it: bool,
|
||||
/// match | mismatch | unknown
|
||||
pub verdict: &'static str,
|
||||
/// the event line, in a user's words
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// Compare the other node with ours: `ours` is the override file the app would start its own node with (the
|
||||
/// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known.
|
||||
pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option<u64>, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) }
|
||||
|
||||
/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal.
|
||||
pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option<u64>, our_network: Option<&str>, theirs: &Check) -> Decision {
|
||||
let mut checked = false;
|
||||
if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) {
|
||||
checked = true;
|
||||
if a != b {
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") };
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) {
|
||||
checked = true;
|
||||
if a != b {
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") };
|
||||
}
|
||||
}
|
||||
if theirs.pow_engine.as_deref() == Some("stub") {
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") };
|
||||
}
|
||||
// every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash)
|
||||
if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) {
|
||||
checked = true;
|
||||
for (k, v) in o {
|
||||
if t.get(k) != Some(v) {
|
||||
let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into());
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") };
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked {
|
||||
Decision { use_it: true, verdict: "match", line: format!("Another node holds port {port} on this machine; using it (same network and rules; it is not stopped by this app)") }
|
||||
} else {
|
||||
Decision { use_it: true, verdict: "unknown", line: format!("Another node holds port {port} on this machine; using it. Its rules could not be checked (an older node that lacks igneum_getNodeInfo), so the app reads it as it is") }
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the app waits after another node leaves its ports before starting its own (the Mac, 7 October 2026: the
|
||||
/// hand node left at 00:18 UK and the app sat on "node stopped" all night, since the mode was decided once at launch).
|
||||
pub const TAKEOVER_WAIT_S: f64 = 60.0;
|
||||
|
||||
/// What the app does about ports another node held, re-checked while attached (external) or refused (none).
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Step {
|
||||
/// the other node still answers on the port
|
||||
Stay,
|
||||
/// the port has been closed for `for_s` seconds; the app waits out TAKEOVER_WAIT_S in case it comes back
|
||||
Gone { for_s: f64 },
|
||||
/// the port stayed closed for TAKEOVER_WAIT_S: start our own node on the freed ports
|
||||
TakeOver,
|
||||
}
|
||||
|
||||
/// `port_open` is whether the other node's RPC port answers now, `gone_since` the app's memory of when it stopped
|
||||
/// answering (None while it answers). A node that comes back inside the wait is kept; the wait starts over.
|
||||
pub fn step(port_open: bool, now_s: f64, gone_since: &mut Option<f64>) -> Step {
|
||||
if port_open {
|
||||
*gone_since = None;
|
||||
return Step::Stay;
|
||||
}
|
||||
let since = *gone_since.get_or_insert(now_s);
|
||||
let for_s = now_s - since;
|
||||
if for_s >= TAKEOVER_WAIT_S {
|
||||
*gone_since = None;
|
||||
Step::TakeOver
|
||||
} else {
|
||||
Step::Gone { for_s }
|
||||
}
|
||||
}
|
||||
|
||||
/// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same).
|
||||
pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option<Value> {
|
||||
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
|
||||
crate::execrpc::call(evm_port, method, params, limit).ok().filter(|r| !r.is_null())
|
||||
}
|
||||
|
||||
/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (newer nodes).
|
||||
pub fn probe(evm_port: u16) -> Check {
|
||||
let mut c = Check::default();
|
||||
if let Some(Value::String(h)) = rpc(evm_port, "eth_chainId", json!([]), Duration::from_secs(4)) {
|
||||
c.chain_id = u64::from_str_radix(h.trim_start_matches("0x"), 16).ok();
|
||||
}
|
||||
if let Some(info) = rpc(evm_port, "igneum_getNodeInfo", json!([]), Duration::from_secs(4)) {
|
||||
c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.overrides = info.get("params").cloned().filter(|v| v.is_object());
|
||||
c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.merge_depth = info.get("blockrate").and_then(|b| b.get("mergeDepth")).and_then(|v| v.as_u64());
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Keep the newest `keep` files named `<prefix>...` in `dir`; the rest go (the node and miner logs grow one per start).
|
||||
pub fn prune_logs(dir: &Path, prefix: &str, keep: usize) -> usize {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
|
||||
let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix) && e.path().extension().map(|x| x == "log").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
|
||||
.collect();
|
||||
files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
let mut removed = 0;
|
||||
for (_, p) in files.into_iter().skip(keep) {
|
||||
if std::fs::remove_file(&p).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ours() -> Value { json!({ "difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "exec_restart_number": 27276, "exec_restart_hash": "bb45cf0d" }) }
|
||||
|
||||
/// The Mac, 6 October 2026: the hand node held the ports; the app said nothing and read it as its own.
|
||||
#[test]
|
||||
fn a_node_on_our_rules_is_used_and_said_so() {
|
||||
let theirs = Check { chain_id: Some(7_777), overrides: Some(ours()), digest: Some("1f4b".into()), ..Default::default() };
|
||||
let d = decide(26611, Some(&ours()), Some(7_777), &theirs);
|
||||
assert!(d.use_it);
|
||||
assert_eq!(d.verdict, "match");
|
||||
assert_eq!(d.line, "Another node holds port 26611 on this machine; using it (same network and rules; it is not stopped by this app)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_on_another_network_or_other_rules_is_refused() {
|
||||
let other_chain = Check { chain_id: Some(7_778), ..Default::default() };
|
||||
let d = decide(26611, Some(&ours()), Some(7_777), &other_chain);
|
||||
assert!(!d.use_it);
|
||||
assert_eq!(d.verdict, "mismatch");
|
||||
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)");
|
||||
let mut theirs = ours(); theirs["fees_v1_activation_daa"] = json!(200000);
|
||||
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() });
|
||||
assert!(!d.use_it);
|
||||
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)");
|
||||
// the merge depth comes from blockrate.mergeDepth (compiled per network), never from params
|
||||
assert_eq!(Check { merge_depth: Some(36_000), ..Default::default() }.merge_depth, Some(36_000));
|
||||
// a stub engine is refused whatever else matches
|
||||
assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it);
|
||||
// an override we have that the other node lacks is a mismatch too
|
||||
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() });
|
||||
assert!(!d.use_it);
|
||||
assert!(d.line.contains("exec_restart_hash none"), "{}", d.line);
|
||||
// the network name (node lane, igneum_getNodeInfo.network) must be equal
|
||||
let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() });
|
||||
assert!(!d.use_it);
|
||||
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)");
|
||||
assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_node_that_cannot_answer_is_used_and_marked_unknown() {
|
||||
let d = decide(26611, Some(&ours()), None, &Check::default());
|
||||
assert!(d.use_it);
|
||||
assert_eq!(d.verdict, "unknown");
|
||||
assert!(d.line.starts_with("Another node holds port 26611 on this machine; using it. Its rules could not be checked"));
|
||||
// a chain id alone, matching, is a check
|
||||
assert_eq!(decide(26611, None, Some(1), &Check { chain_id: Some(1), ..Default::default() }).verdict, "match");
|
||||
}
|
||||
|
||||
/// The Mac, 7 October 2026 00:18 UK: the hand node left and the app never started its own (today's app never recovers).
|
||||
#[test]
|
||||
fn an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait() {
|
||||
let mut gone = None;
|
||||
assert_eq!(step(true, 0.0, &mut gone), Step::Stay);
|
||||
assert_eq!(gone, None);
|
||||
assert_eq!(step(false, 100.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||
assert_eq!(step(false, 130.0, &mut gone), Step::Gone { for_s: 30.0 });
|
||||
assert_eq!(step(false, 160.0, &mut gone), Step::TakeOver);
|
||||
assert_eq!(gone, None, "the memory resets once the app has its own node");
|
||||
// a node that comes back inside the wait is kept, and the wait starts over
|
||||
let mut gone = None;
|
||||
assert_eq!(step(false, 0.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||
assert_eq!(step(true, 30.0, &mut gone), Step::Stay);
|
||||
assert_eq!(step(false, 40.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||
assert_eq!(step(false, 99.0, &mut gone), Step::Gone { for_s: 59.0 });
|
||||
assert_eq!(step(false, 100.0, &mut gone), Step::TakeOver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_the_newest_logs() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..6 {
|
||||
let p = dir.join(format!("node-2026100{i}.log"));
|
||||
std::fs::write(&p, "x").unwrap();
|
||||
let t = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + i * 60);
|
||||
let f = std::fs::File::options().write(true).open(&p).unwrap();
|
||||
f.set_modified(t).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("miner-1.log"), "y").unwrap();
|
||||
std::fs::write(dir.join("node-notes.txt"), "z").unwrap();
|
||||
assert_eq!(prune_logs(&dir, "node-", 4), 2);
|
||||
let mut left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
left.sort();
|
||||
assert_eq!(left, vec!["miner-1.log", "node-20261002.log", "node-20261003.log", "node-20261004.log", "node-20261005.log", "node-notes.txt"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
487
app/igneum-app/src/heat.rs
Normal file
487
app/igneum-app/src/heat.rs
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
//! Ember Heat (mission item 7, 7 October 2026, docs/plans/ember-heat.md): the card is a heater that also earns. Heat mode
|
||||
//! holds a room temperature, or a schedule of them, and the hash follows the duty cycle: the cards mine for a share of
|
||||
//! every ten-minute period and rest for the rest of it. The chain sees a miner with a schedule, nothing else.
|
||||
//!
|
||||
//! The temperature source is what the machine has: a reading the miner types from their own thermometer (fresh for
|
||||
//! two hours), else the card's own sensor once the card has rested long enough to cool to the room plus an idle
|
||||
//! offset (default 8 degrees, approximate; learned from a typed reading taken while the card rests). No hardware
|
||||
//! the app does not have. Without any reading the loop heats 70 percent of every period and says so.
|
||||
//!
|
||||
//! The loop is proportional plus integral, decided once per period on the reading at the period's start: the
|
||||
//! integral carries the steady-state share of heat the room needs, the proportional term pulls it back when the
|
||||
//! room drifts. The engine (src/engine.rs, `tick_heat`) owns the processes: it stops the miners for a rest and
|
||||
//! re-arms them for the heating slice; nothing here touches a card. The log carries one `HEAT` line every 30 s and
|
||||
//! tools/heat-gate.mjs reads it for the PC 1 gate (held within 1 degree for 4 hours, hash following the duty).
|
||||
|
||||
/// One period: the heating slice first, the rest after it.
|
||||
pub const PERIOD_S: f64 = 600.0;
|
||||
/// Duty per degree under the set point (2 degrees under = a full period of heat).
|
||||
pub const KP: f64 = 0.5;
|
||||
/// Duty per degree per period added to the integral (the steady-state share).
|
||||
pub const KI: f64 = 0.05;
|
||||
/// The card sensor stands for the room only after this long at rest (the die cools toward the room plus the idle
|
||||
/// offset; what is left of the cooling tail is taken off by its slope, COOL_TAU_S).
|
||||
pub const SETTLE_S: f64 = 180.0;
|
||||
/// The cooling tail of a stopped card as one time constant, seconds, approximate: the estimate adds tau times the
|
||||
/// (negative) slope of the sensor, which is exact for a single exponential and partial otherwise.
|
||||
pub const COOL_TAU_S: f64 = 60.0;
|
||||
/// A typed room reading is the room for this long.
|
||||
pub const TYPED_FRESH_S: f64 = 7200.0;
|
||||
/// An idle card's sensor above the room, degrees, approximate, until a typed reading teaches the real offset.
|
||||
pub const OFFSET_DEFAULT_C: f64 = 8.0;
|
||||
/// The stated error of the card-sensor estimate, degrees.
|
||||
pub const OFFSET_ERROR_C: f64 = 3.0;
|
||||
/// With the card sensor as the only source, every period keeps a rest long enough to read the room.
|
||||
pub const CARD_DUTY_MAX: f64 = 1.0 - SETTLE_S / PERIOD_S;
|
||||
/// With no reading at all: heat this share of every period (the rest long enough for the card to read the room) and say why.
|
||||
pub const FIND_DUTY: f64 = CARD_DUTY_MAX;
|
||||
/// The set point the app accepts, degrees.
|
||||
pub const SET_MIN_C: f64 = 5.0;
|
||||
pub const SET_MAX_C: f64 = 30.0;
|
||||
/// The log line and the gate tool's sample spacing.
|
||||
pub const LOG_EVERY_S: f64 = 30.0;
|
||||
|
||||
/// One entry of a schedule: from this minute of the day the set point is `set_c`, until the next entry.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Slot {
|
||||
pub minute: u32,
|
||||
pub set_c: f64,
|
||||
}
|
||||
|
||||
/// "06:00 20, 22:00 16" to slots, sorted by minute. Empty text = no schedule (the one set point all day).
|
||||
pub fn parse_schedule(text: &str) -> Result<Vec<Slot>, String> {
|
||||
let mut out = Vec::new();
|
||||
for part in text.split(|c| c == ',' || c == ';' || c == '\n') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut it = part.split_whitespace();
|
||||
let (Some(time), Some(temp)) = (it.next(), it.next()) else {
|
||||
return Err(format!("\"{part}\": write a time and a temperature, for example 06:00 20"));
|
||||
};
|
||||
let (h, m) = time.split_once(':').ok_or_else(|| format!("\"{time}\": a time is HH:MM"))?;
|
||||
let h: u32 = h.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
|
||||
let m: u32 = m.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
|
||||
if h > 23 || m > 59 {
|
||||
return Err(format!("\"{time}\": a time is HH:MM, 00:00 to 23:59"));
|
||||
}
|
||||
let set_c: f64 = temp.trim_end_matches("°C").trim_end_matches('C').parse().map_err(|_| format!("\"{temp}\": a temperature is a number of degrees"))?;
|
||||
if !(SET_MIN_C..=SET_MAX_C).contains(&set_c) {
|
||||
return Err(format!("{set_c} degrees: the set point is {SET_MIN_C:.0} to {SET_MAX_C:.0}"));
|
||||
}
|
||||
out.push(Slot { minute: h * 60 + m, set_c });
|
||||
}
|
||||
out.sort_by_key(|s| s.minute);
|
||||
out.dedup_by_key(|s| s.minute);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Slots back to the text the settings page shows.
|
||||
pub fn schedule_text(slots: &[Slot]) -> String {
|
||||
slots.iter().map(|s| format!("{:02}:{:02} {}", s.minute / 60, s.minute % 60, trim_c(s.set_c))).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
fn trim_c(c: f64) -> String {
|
||||
if (c - c.round()).abs() < 0.05 { format!("{:.0}", c) } else { format!("{:.1}", c) }
|
||||
}
|
||||
|
||||
/// The set point in force at a minute of the day: the latest slot at or before it; before the first slot, the last
|
||||
/// slot of the day (the schedule wraps at midnight). No slots: the default.
|
||||
pub fn set_point_at(default_c: f64, slots: &[Slot], minute_of_day: u32) -> f64 {
|
||||
if slots.is_empty() {
|
||||
return default_c;
|
||||
}
|
||||
slots.iter().rev().find(|s| s.minute <= minute_of_day).or_else(|| slots.last()).map(|s| s.set_c).unwrap_or(default_c)
|
||||
}
|
||||
|
||||
/// The minute of the day in the schedule's own clock: unix seconds plus the window's offset east of UTC in minutes.
|
||||
pub fn minute_of_day(unix: f64, tz_east_min: i32) -> u32 {
|
||||
let local = unix as i64 + tz_east_min as i64 * 60;
|
||||
((local.rem_euclid(86_400)) / 60) as u32
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
Typed,
|
||||
Card,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Source::Typed => "typed",
|
||||
Source::Card => "card",
|
||||
Source::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the loop knows about the room right now.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Reading {
|
||||
pub room_c: f64,
|
||||
pub source: Source,
|
||||
/// the stated error of the estimate, degrees (0 for a typed reading: the thermometer is the miner's)
|
||||
pub error_c: f64,
|
||||
/// how old the reading is, seconds
|
||||
pub age_s: f64,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
pub fn none() -> Reading {
|
||||
Reading { room_c: 0.0, source: Source::None, error_c: 0.0, age_s: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The room estimate: a typed reading while it is fresh, else the coolest card's sensor minus the idle offset once
|
||||
/// the cards have rested SETTLE_S (the cooling tail still in the sensor taken off by its slope), else nothing.
|
||||
/// `typed` = (degrees, unix s typed); `card_c` = 0 when no card reports; `card_slope` = degrees per second over the
|
||||
/// last half minute (negative while cooling; a rising sensor is not corrected).
|
||||
pub fn room(now: f64, typed: Option<(f64, f64)>, card_c: f64, card_slope: f64, rest_for_s: f64, offset_c: f64) -> Reading {
|
||||
if let Some((c, at)) = typed {
|
||||
if c > -50.0 && at > 0.0 && now - at < TYPED_FRESH_S {
|
||||
return Reading { room_c: c, source: Source::Typed, error_c: 0.0, age_s: (now - at).max(0.0) };
|
||||
}
|
||||
}
|
||||
if card_c > 0.0 && rest_for_s >= SETTLE_S {
|
||||
let off = if offset_c > 0.0 { offset_c } else { OFFSET_DEFAULT_C };
|
||||
let tail = COOL_TAU_S * card_slope.min(0.0);
|
||||
return Reading { room_c: card_c + tail - off, source: Source::Card, error_c: OFFSET_ERROR_C, age_s: 0.0 };
|
||||
}
|
||||
Reading::none()
|
||||
}
|
||||
|
||||
/// The slope of sensor samples (unix s, degrees) at the END of the window, degrees per second: the least-squares
|
||||
/// line (its slope belongs to the window's middle) brought forward by the exponential tail's own decay over half
|
||||
/// the window. 0 with fewer than two samples.
|
||||
pub fn slope_of<I: Iterator<Item = (f64, f64)>>(samples: I) -> f64 {
|
||||
let v: Vec<(f64, f64)> = samples.collect();
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let n = v.len() as f64;
|
||||
let (mt, mc) = (v.iter().map(|s| s.0).sum::<f64>() / n, v.iter().map(|s| s.1).sum::<f64>() / n);
|
||||
let (mut num, mut den) = (0.0, 0.0);
|
||||
for (t, c) in &v {
|
||||
num += (t - mt) * (c - mc);
|
||||
den += (t - mt) * (t - mt);
|
||||
}
|
||||
if den <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let span = v.iter().map(|s| s.0).fold(f64::MIN, f64::max) - v.iter().map(|s| s.0).fold(f64::MAX, f64::min);
|
||||
num / den * (-(span / 2.0) / COOL_TAU_S).exp()
|
||||
}
|
||||
|
||||
/// A typed reading taken while the cards have rested teaches the idle offset (card minus room, 0 to 20 degrees).
|
||||
pub fn learned_offset(card_c: f64, rest_for_s: f64, typed_c: f64) -> Option<f64> {
|
||||
if card_c <= 0.0 || rest_for_s < SETTLE_S {
|
||||
return None;
|
||||
}
|
||||
Some((card_c - typed_c).clamp(0.0, 20.0))
|
||||
}
|
||||
|
||||
/// The loop's memory between ticks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Controller {
|
||||
/// the steady-state share of heat the room needs, 0 to 1 (starts at a half)
|
||||
pub integral: f64,
|
||||
pub period_start: f64,
|
||||
/// this period's duty, 0 to 1
|
||||
pub duty: f64,
|
||||
/// this period's heating slice, seconds
|
||||
pub on_s: f64,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
/// What the engine does this tick.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Decision {
|
||||
pub heating: bool,
|
||||
pub duty: f64,
|
||||
pub set_c: f64,
|
||||
pub reading: Reading,
|
||||
/// seconds until the slice changes (heating to rest, or the next period)
|
||||
pub until_s: f64,
|
||||
/// a new period began on this tick
|
||||
pub new_period: bool,
|
||||
}
|
||||
|
||||
impl Default for Controller {
|
||||
fn default() -> Controller {
|
||||
Controller::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new() -> Controller {
|
||||
Controller { integral: 0.5, period_start: 0.0, duty: 0.0, on_s: 0.0, started: false }
|
||||
}
|
||||
|
||||
/// The duty a reading asks for, and the integral it leaves: the proportional term on the error, the integral on
|
||||
/// the steady-state share; with no reading the find share and an untouched integral.
|
||||
pub fn duty_for(&mut self, set_c: f64, reading: &Reading) -> f64 {
|
||||
match reading.source {
|
||||
Source::None => FIND_DUTY,
|
||||
src => {
|
||||
let err = set_c - reading.room_c;
|
||||
self.integral = (self.integral + KI * err).clamp(0.0, 1.0);
|
||||
let d = (KP * err + self.integral).clamp(0.0, 1.0);
|
||||
if src == Source::Card { d.min(CARD_DUTY_MAX) } else { d }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick. A new period is decided on the reading at its start; inside a period only the clock moves.
|
||||
pub fn step(&mut self, now: f64, set_c: f64, reading: Reading) -> Decision {
|
||||
let mut new_period = false;
|
||||
if !self.started || now >= self.period_start + PERIOD_S {
|
||||
new_period = true;
|
||||
self.started = true;
|
||||
self.period_start = now;
|
||||
self.duty = self.duty_for(set_c, &reading);
|
||||
self.on_s = if self.duty >= 0.999 { PERIOD_S } else if self.duty <= 0.001 { 0.0 } else { (self.duty * PERIOD_S).round() };
|
||||
}
|
||||
let heating = now < self.period_start + self.on_s;
|
||||
let until_s = if heating { self.period_start + self.on_s - now } else { self.period_start + PERIOD_S - now };
|
||||
Decision { heating, duty: self.duty, set_c, reading, until_s: until_s.max(0.0), new_period }
|
||||
}
|
||||
|
||||
/// Heat mode switched off or on again: the next tick starts a fresh period; the learned share stays.
|
||||
pub fn reset(&mut self) {
|
||||
self.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The `HEAT` log line the gate tool reads (tools/heat-gate.mjs): one every LOG_EVERY_S while heat mode is on.
|
||||
pub fn log_line(unix: f64, phase: &str, set_c: f64, reading: &Reading, duty: f64, heat_w: f64, hash_mhs: f64, until_s: f64) -> String {
|
||||
format!(
|
||||
"HEAT t={} phase={} set={:.1} room={} src={} duty={:.2} heat_w={:.0} hash={:.1} until={:.0}",
|
||||
unix as u64,
|
||||
phase,
|
||||
set_c,
|
||||
if reading.source == Source::None { "-".to_string() } else { format!("{:.1}", reading.room_c) },
|
||||
reading.source.name(),
|
||||
duty,
|
||||
heat_w,
|
||||
hash_mhs,
|
||||
until_s
|
||||
)
|
||||
}
|
||||
|
||||
/// The one line under the switch: what the loop is doing, in the miner's words.
|
||||
pub fn words(on: bool, phase: &str, set_c: f64, reading: &Reading, duty: f64, until_s: f64) -> String {
|
||||
if !on {
|
||||
return "Off. The cards mine whenever the node is synced.".into();
|
||||
}
|
||||
let room = match reading.source {
|
||||
Source::Typed => format!("room {:.1} °C from your reading {}", reading.room_c, if reading.age_s < 90.0 { "just now".to_string() } else { format!("{} min ago", (reading.age_s / 60.0).round() as u64) }),
|
||||
Source::Card => format!("room about {:.0} °C from the card's sensor (within {:.0} degrees)", reading.room_c, reading.error_c),
|
||||
Source::None => "no room reading yet: type one, or the card reads it after 2 minutes of rest".into(),
|
||||
};
|
||||
let doing = match phase {
|
||||
"heating" => format!("heating, {} to go", mins(until_s)),
|
||||
"resting" => format!("resting, heats again in {}", mins(until_s)),
|
||||
"paused" => "mining is paused, so nothing heats".into(),
|
||||
"waiting" => "waiting for the node".into(),
|
||||
_ => phase.to_string(),
|
||||
};
|
||||
format!("Holding {:.1} °C: {}. {}. Heat {:.0}% of the time.", set_c, room, doing, duty * 100.0)
|
||||
}
|
||||
|
||||
fn mins(s: f64) -> String {
|
||||
let m = (s / 60.0).round() as u64;
|
||||
if s < 45.0 { "under a minute".into() } else if m <= 1 { "a minute".into() } else { format!("{m} min") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A room as a first-order store: C joules per degree, K watts per degree of loss to the outside, P_full watts
|
||||
/// from the cards while they heat. The card's die sits offset_c above the room at rest and rise_c more while
|
||||
/// mining, settling with a one-minute time constant.
|
||||
struct Sim {
|
||||
room_c: f64,
|
||||
out_c: f64,
|
||||
c_j_per_k: f64,
|
||||
k_w_per_k: f64,
|
||||
p_full_w: f64,
|
||||
card_rise_c: f64,
|
||||
offset_c: f64,
|
||||
rise_now: f64,
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
fn new(room_c: f64) -> Sim {
|
||||
Sim { room_c, out_c: 10.0, c_j_per_k: 400_000.0, k_w_per_k: 20.0, p_full_w: 300.0, card_rise_c: 40.0, offset_c: 8.0, rise_now: 0.0 }
|
||||
}
|
||||
fn tick(&mut self, heating: bool, dt: f64) {
|
||||
let p = if heating { self.p_full_w } else { 0.0 };
|
||||
self.room_c += (p - self.k_w_per_k * (self.room_c - self.out_c)) * dt / self.c_j_per_k;
|
||||
let target = if heating { self.card_rise_c } else { 0.0 };
|
||||
self.rise_now += (target - self.rise_now) * (1.0 - (-dt / 60.0).exp());
|
||||
}
|
||||
fn card_c(&self) -> f64 {
|
||||
self.room_c + self.offset_c + self.rise_now
|
||||
}
|
||||
}
|
||||
|
||||
/// Four hours after the first hour, the room stays within a degree of the set point and the hash is on exactly
|
||||
/// while the slice heats: the gate's shape (docs/plans/ember-heat.md), on a model room with the typed reading
|
||||
/// refreshed every half hour, as a miner with a thermometer on the desk would do.
|
||||
#[test]
|
||||
fn a_typed_reading_holds_the_room_within_a_degree_for_four_hours() {
|
||||
let mut sim = Sim::new(19.0);
|
||||
let mut ctl = Controller::new();
|
||||
let set = 20.0;
|
||||
let t0 = 1000.0;
|
||||
let mut t = t0;
|
||||
let mut typed = (sim.room_c, t0);
|
||||
let mut worst: f64 = 0.0;
|
||||
let mut heating_s = 0.0;
|
||||
let mut hash_on_s = 0.0;
|
||||
while t < t0 + 5.0 * 3600.0 {
|
||||
if t - typed.1 >= 1800.0 {
|
||||
typed = (sim.room_c, t);
|
||||
}
|
||||
let r = room(t, Some(typed), sim.card_c(), 0.0, 0.0, 0.0);
|
||||
assert_eq!(r.source, Source::Typed);
|
||||
let d = ctl.step(t, set, r);
|
||||
let hash = if d.heating { 100.0 } else { 0.0 };
|
||||
sim.tick(d.heating, 1.0);
|
||||
if t >= t0 + 3600.0 {
|
||||
worst = worst.max((sim.room_c - set).abs());
|
||||
if d.heating { heating_s += 1.0; }
|
||||
if hash > 0.0 { hash_on_s += 1.0; }
|
||||
}
|
||||
t += 1.0;
|
||||
}
|
||||
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
|
||||
assert_eq!(heating_s, hash_on_s, "the hash was on exactly while the slice heated");
|
||||
// the room needs 20 W per degree over 10 degrees = 200 W of 300: a duty near two thirds
|
||||
assert!((ctl.integral - 0.667).abs() < 0.15, "the integral found the steady share: {:.2}", ctl.integral);
|
||||
assert!(heating_s / (4.0 * 3600.0) > 0.5 && heating_s / (4.0 * 3600.0) < 0.85, "the hash followed the duty: {:.2}", heating_s / (4.0 * 3600.0));
|
||||
}
|
||||
|
||||
/// The card's own sensor as the only source: every period keeps three minutes of rest, the reading is taken after
|
||||
/// that rest with the cooling tail taken off, and the room still holds within a degree over the four hours after
|
||||
/// the first. The sensor's slope comes from the last half minute of samples, as the engine takes it.
|
||||
#[test]
|
||||
fn the_card_sensor_alone_holds_the_room_within_a_degree() {
|
||||
let mut sim = Sim::new(19.0);
|
||||
let mut ctl = Controller::new();
|
||||
let set = 20.0;
|
||||
let mut t = 0.0;
|
||||
let mut rest_since: Option<f64> = None;
|
||||
let mut worst: f64 = 0.0;
|
||||
let mut estimate_err: f64 = 0.0;
|
||||
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
|
||||
while t < 5.0 * 3600.0 {
|
||||
let rest_for = rest_since.map(|s| t - s).unwrap_or(0.0);
|
||||
samples.push_back((t, sim.card_c()));
|
||||
while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); }
|
||||
let slope = slope_of(samples.iter().copied());
|
||||
let r = room(t, None, sim.card_c(), slope, rest_for, 0.0);
|
||||
let d = ctl.step(t, set, r);
|
||||
if d.new_period {
|
||||
assert!(d.duty <= CARD_DUTY_MAX + 1e-9, "a card-sensed period keeps its rest: {}", d.duty);
|
||||
if r.source == Source::Card {
|
||||
estimate_err = estimate_err.max((r.room_c - sim.room_c).abs());
|
||||
}
|
||||
}
|
||||
sim.tick(d.heating, 1.0);
|
||||
rest_since = if d.heating { None } else { Some(rest_since.unwrap_or(t)) };
|
||||
if t >= 3600.0 {
|
||||
worst = worst.max((sim.room_c - set).abs());
|
||||
}
|
||||
t += 1.0;
|
||||
}
|
||||
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
|
||||
assert!(estimate_err < OFFSET_ERROR_C, "the card estimate stayed inside its stated error: {estimate_err:.2}");
|
||||
}
|
||||
|
||||
/// Without the slope correction the sensor still carries the cooling tail at the end of the rest and the
|
||||
/// estimate reads high by more than the stated error: the correction is what makes the card path honest.
|
||||
#[test]
|
||||
fn the_cooling_tail_is_taken_off_by_the_slope() {
|
||||
let mut sim = Sim::new(19.0);
|
||||
for _ in 0..600 { sim.tick(true, 1.0); }
|
||||
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
|
||||
let mut t = 0.0;
|
||||
for _ in 0..(SETTLE_S as usize) { sim.tick(false, 1.0); t += 1.0; samples.push_back((t, sim.card_c())); while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); } }
|
||||
let slope = slope_of(samples.iter().copied());
|
||||
let raw = room(t, None, sim.card_c(), 0.0, SETTLE_S, 8.0);
|
||||
let fixed = room(t, None, sim.card_c(), slope, SETTLE_S, 8.0);
|
||||
assert!(raw.room_c - sim.room_c > 1.0, "the raw sensor still reads the tail: {:.2} over", raw.room_c - sim.room_c);
|
||||
assert!((fixed.room_c - sim.room_c).abs() < 0.5, "the corrected estimate is the room: {:.2} off", fixed.room_c - sim.room_c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_reading_the_loop_heats_the_find_share_and_says_so() {
|
||||
let mut ctl = Controller::new();
|
||||
let d = ctl.step(1000.0, 20.0, Reading::none());
|
||||
assert_eq!(d.duty, FIND_DUTY);
|
||||
assert!(d.heating);
|
||||
assert_eq!(ctl.integral, 0.5, "no reading leaves the integral alone");
|
||||
assert!(words(true, "heating", 20.0, &d.reading, d.duty, d.until_s).contains("no room reading yet"));
|
||||
// the slice ends at 80% of the period, the rest runs to the period's end
|
||||
let d2 = ctl.step(1000.0 + FIND_DUTY * PERIOD_S + 1.0, 20.0, Reading::none());
|
||||
assert!(!d2.heating);
|
||||
assert!(!d2.new_period);
|
||||
assert!(d2.until_s <= (1.0 - FIND_DUTY) * PERIOD_S);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_room_source_order_is_typed_then_card_then_none() {
|
||||
let typed = Some((19.5, 1000.0));
|
||||
assert_eq!(room(1500.0, typed, 30.0, 0.0, 300.0, 0.0).source, Source::Typed);
|
||||
let stale = room(1000.0 + TYPED_FRESH_S + 1.0, typed, 30.0, 0.0, 300.0, 0.0);
|
||||
assert_eq!(stale.source, Source::Card);
|
||||
assert!((stale.room_c - 22.0).abs() < 1e-9, "card 30 minus the default offset 8");
|
||||
assert_eq!(room(1500.0, None, 30.0, 0.0, 60.0, 0.0).source, Source::None, "a card still warm from mining is not the room");
|
||||
assert_eq!(room(1500.0, None, 0.0, 0.0, 600.0, 0.0).source, Source::None, "no card sensor at all");
|
||||
assert!((room(1500.0, None, 30.0, 0.02, 300.0, 0.0).room_c - 22.0).abs() < 1e-9, "a rising sensor is not corrected");
|
||||
let learned = room(1500.0, None, 30.0, 0.0, 300.0, 11.0);
|
||||
assert!((learned.room_c - 19.0).abs() < 1e-9, "a learned offset replaces the default");
|
||||
assert_eq!(learned_offset(30.0, 300.0, 19.0), Some(11.0));
|
||||
assert_eq!(learned_offset(30.0, 30.0, 19.0), None, "a card that has not rested teaches nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_parses_sorts_and_wraps_at_midnight() {
|
||||
let s = parse_schedule("22:00 16, 06:00 20").unwrap();
|
||||
assert_eq!(s, vec![Slot { minute: 360, set_c: 20.0 }, Slot { minute: 1320, set_c: 16.0 }]);
|
||||
assert_eq!(schedule_text(&s), "06:00 20, 22:00 16");
|
||||
assert_eq!(set_point_at(19.0, &s, 7 * 60), 20.0);
|
||||
assert_eq!(set_point_at(19.0, &s, 23 * 60), 16.0);
|
||||
assert_eq!(set_point_at(19.0, &s, 2 * 60), 16.0, "before the first slot the last one of the day holds");
|
||||
assert_eq!(set_point_at(19.0, &[], 2 * 60), 19.0);
|
||||
assert!(parse_schedule("06:00").is_err());
|
||||
assert!(parse_schedule("25:00 20").is_err());
|
||||
assert!(parse_schedule("06:00 40").is_err(), "the set point is 5 to 30");
|
||||
assert_eq!(parse_schedule("").unwrap(), vec![]);
|
||||
assert_eq!(minute_of_day(0.0, 60), 60, "one hour east of UTC at midnight UTC is 01:00");
|
||||
assert_eq!(minute_of_day(0.0, -300), 19 * 60, "five hours west wraps to the evening before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_line_carries_what_the_gate_reads() {
|
||||
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 30.0 };
|
||||
let l = log_line(1_759_800_000.0, "heating", 20.0, &r, 0.6, 180.0, 74.2, 312.0);
|
||||
assert_eq!(l, "HEAT t=1759800000 phase=heating set=20.0 room=19.6 src=typed duty=0.60 heat_w=180 hash=74.2 until=312");
|
||||
let l2 = log_line(1.0, "resting", 20.0, &Reading::none(), 0.8, 0.0, 0.0, 10.0);
|
||||
assert!(l2.contains("room=- src=none"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_words_name_the_source_the_slice_and_the_share() {
|
||||
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 2400.0 };
|
||||
assert_eq!(words(true, "heating", 20.0, &r, 0.6, 312.0), "Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time.");
|
||||
let c = Reading { room_c: 19.0, source: Source::Card, error_c: 3.0, age_s: 0.0 };
|
||||
assert_eq!(words(true, "resting", 20.0, &c, 0.5, 100.0), "Holding 20.0 °C: room about 19 °C from the card's sensor (within 3 degrees). resting, heats again in 2 min. Heat 50% of the time.");
|
||||
assert!(words(false, "off", 20.0, &c, 0.0, 0.0).starts_with("Off."));
|
||||
}
|
||||
}
|
||||
|
|
@ -167,6 +167,10 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
|
|||
c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz };
|
||||
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
|
||||
c.tune_source = p.sweep_source.clone();
|
||||
c.tune_goal = p.tune_goal.clone();
|
||||
c.tune_before_watts = p.sweep_before_watts;
|
||||
c.tune_before_mhs = p.sweep_before_mhs;
|
||||
c.tune_floor = p.sweep_floor;
|
||||
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
|
||||
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ pub enum Action {
|
|||
StopMiners(String),
|
||||
RestartMiners,
|
||||
RestartNode,
|
||||
/// The signed `cards` kind: apply these per-card choices through the app's own card path (persisted), then
|
||||
/// call `cards_applied` with the read-back.
|
||||
ApplyCards(Vec<crate::engine::CardChoice>),
|
||||
/// The relaunch helper was started; the engine quits now.
|
||||
RestartApp,
|
||||
UpdateNow,
|
||||
|
|
@ -137,6 +140,12 @@ pub fn key_without_index(k: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// The restore list of a `cards_leave_off` job: the same entries (identities and cap kept) with enabled=false, so the
|
||||
/// runner's restore on any exit leaves the card off, persisted by the app's own card path.
|
||||
pub fn leave_off(restore: Vec<crate::engine::CardChoice>) -> Vec<crate::engine::CardChoice> {
|
||||
restore.into_iter().map(|mut c| { c.enabled = false; c }).collect()
|
||||
}
|
||||
|
||||
/// One live card as `cards_off_choices` sees it: key, enabled, identities, power_pct, present.
|
||||
pub type LiveCard = (String, bool, u32, u32, bool);
|
||||
|
||||
|
|
@ -239,11 +248,15 @@ impl Jobs {
|
|||
active: None,
|
||||
needs_logged: std::collections::HashSet::new(),
|
||||
fingerprint: manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX),
|
||||
wake: Arc::new(WakeCtl { on: AtomicBool::new(allowed) }),
|
||||
wake: Arc::new(WakeCtl::new(allowed, &shared.runtime.id8())),
|
||||
wake_pending: false,
|
||||
last_published: String::new(),
|
||||
};
|
||||
j.publish(shared);
|
||||
// the ping names the last job this machine ran, across restarts (the newest record in jobs-state.json)
|
||||
if let Some(last) = j.ledger.records.values().max_by_key(|r| r.started_at) {
|
||||
j.wake.set_last_job(&last.id);
|
||||
}
|
||||
if !j.url.is_empty() {
|
||||
let wake_url = wake_url_of(std::env::var("IGNEUM_APP_JOBS_WAKE_URL").ok());
|
||||
if !wake_url.is_empty() {
|
||||
|
|
@ -559,11 +572,29 @@ impl Jobs {
|
|||
return None;
|
||||
}
|
||||
shared.event("info", &format!("job {} ({}) starts: {}", job.id, job.kind, job.label()));
|
||||
self.wake.set_last_job(&job.id);
|
||||
let ctl = Arc::new(Ctl::default());
|
||||
let needs_miners_stopped = job.kind == "shard-benchmark" || (job.kind == "run" && job.bool_param("stop_miners_first"));
|
||||
let cards_off: Vec<String> = if job.kind == "run" { job.list_param("cards_off") } else { vec![] };
|
||||
self.active = Some(Active { job: job.clone(), run_id: run_id.clone(), started: Instant::now(), started_unix: now, waiting_for_miners: needs_miners_stopped, holds_miners: false, waiting_for_cards: !cards_off.is_empty(), cards: CardHold::default(), ctl: ctl.clone() });
|
||||
match job.kind.as_str() {
|
||||
"cards" => {
|
||||
// engine-side: refused at once for a card this machine does not have; else applied through the
|
||||
// app's own card path and reported with the read-back (cards_applied)
|
||||
let live: Vec<(String, bool, u32)> = shared.state.lock().unwrap().mining.cards.iter().filter(|c| c.present()).map(|c| (c.key.clone(), c.enabled, c.identities)).collect();
|
||||
let (choices, missing) = cards_job_choices(&job, &live);
|
||||
let sink = Sink::new(shared, &job, &self.dir);
|
||||
if !missing.is_empty() {
|
||||
let summary = format!("refused: this machine has no card {}", missing.join(", "));
|
||||
sink.line(&format!("cards: {summary}; present: {}", live.iter().map(|c| c.0.as_str()).collect::<Vec<_>>().join(", ")));
|
||||
let uploaded = report(shared, &job, &sink, "failed", 2, now, crate::platform::unix_now(), &summary, json!({ "present": live.iter().map(|c| c.0.clone()).collect::<Vec<_>>() }));
|
||||
let o = Outcome { status: "failed".into(), exit: 2, summary, results: vec![], uploaded };
|
||||
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o });
|
||||
return None;
|
||||
}
|
||||
sink.line(&format!("cards: applying {}", choices.iter().map(|c| format!("{} enabled={} identities={}{}", c.key, c.enabled, c.identities, c.power_pct.map(|p| format!(" power_pct={p}")).unwrap_or_default())).collect::<Vec<_>>().join("; ")));
|
||||
return Some(Action::ApplyCards(choices));
|
||||
}
|
||||
"restart" | "update-now" => {
|
||||
// engine-side; the report says what was asked and the ledger closes at once
|
||||
let what = job.str_param("what");
|
||||
|
|
@ -597,6 +628,35 @@ impl Jobs {
|
|||
}
|
||||
}
|
||||
|
||||
/// The running job's id, for the hold rule (a hold belongs to the job that took it).
|
||||
pub fn active_id(&self) -> Option<String> {
|
||||
self.active.as_ref().map(|a| a.job.id.clone())
|
||||
}
|
||||
|
||||
/// The running job's cap in minutes (the hold's hard cap).
|
||||
pub fn active_cap_minutes(&self) -> u64 {
|
||||
self.active.as_ref().map(|a| a.job.timeout_minutes()).unwrap_or(60)
|
||||
}
|
||||
|
||||
/// Called by the engine once a `cards` job's choices are applied: the report carries the read-back of every
|
||||
/// card named (what the engine holds now) and the job closes done.
|
||||
pub fn cards_applied(&mut self, shared: &Arc<Shared>, readback: Vec<(String, bool, u32, u32)>) -> Option<Action> {
|
||||
let Some(a) = self.active.as_ref() else { return None };
|
||||
if a.job.kind != "cards" {
|
||||
return None;
|
||||
}
|
||||
let (job, started) = (a.job.clone(), a.started_unix);
|
||||
let sink = Sink::new(shared, &job, &self.dir);
|
||||
let lines: Vec<String> = readback.iter().map(|(k, e, i, p)| format!("{k} enabled={e} identities={i} power_pct={p}")).collect();
|
||||
for l in &lines {
|
||||
sink.line(&format!("cards: read back {l}"));
|
||||
}
|
||||
let summary = format!("cards applied: {}", lines.join("; "));
|
||||
let uploaded = report(shared, &job, &sink, "done", 0, started, crate::platform::unix_now(), &summary, json!({ "cards": readback.iter().map(|(k, e, i, p)| json!({ "key": k, "enabled": e, "identities": i, "power_pct": p })).collect::<Vec<_>>() }));
|
||||
let o = Outcome { status: "done".into(), exit: 0, summary, results: lines, uploaded };
|
||||
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o })
|
||||
}
|
||||
|
||||
/// Called by the engine once a job's `--cards-off` cards are switched off; `restore` puts them back exactly.
|
||||
/// Next: the miners, if the job asked for them too, else the script.
|
||||
pub fn cards_off_done(&mut self, shared: &Arc<Shared>, restore: Vec<crate::engine::CardChoice>) -> Option<Action> {
|
||||
|
|
@ -605,6 +665,10 @@ impl Jobs {
|
|||
return None;
|
||||
}
|
||||
a.waiting_for_cards = false;
|
||||
// cards_leave_off (7 October 2026, intel-arc): the hold still ends through the app's own card path on any
|
||||
// exit, but with enabled=false, so the card stays off and persisted (settings.json) after the job: the way
|
||||
// to hold a card out of mining past a job without a script touching api/cards (the 6 October rule).
|
||||
let restore = if a.job.bool_param("cards_leave_off") { leave_off(restore) } else { restore };
|
||||
a.cards = CardHold::hold(restore);
|
||||
if a.waiting_for_miners {
|
||||
return Some(Action::StopMiners(format!("job {}: {}", a.job.id, a.job.label())));
|
||||
|
|
@ -630,7 +694,11 @@ impl Jobs {
|
|||
if held.is_empty() {
|
||||
sink.line(&format!("cards-off: {} asked, no present card matched (nothing switched; the script's card may be loaded)", asked.join(",")));
|
||||
} else {
|
||||
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
|
||||
if job.bool_param("cards_leave_off") {
|
||||
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards LEFT OFF: {} by the runner on any exit (done, failed, timeout, aborted, app quit), enabled=false with their own identities and cap, persisted by the app (cards_leave_off)", held.keys(), held.keys()));
|
||||
} else {
|
||||
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ctx = account_context();
|
||||
|
|
@ -764,9 +832,43 @@ fn upload_file(shared: &Arc<Shared>, job: &Job, path: &Path, label_prefix: &str)
|
|||
// busy-loops: a reply that came back early is followed by the rest of a 10 s floor, a failing endpoint backs off
|
||||
// 5, 15, then 60 s, and an empty stamp (nothing published yet) waits a full hold.
|
||||
|
||||
/// Shared with the waker thread: it polls only while remote jobs are allowed.
|
||||
/// Shared with the waker thread: it polls only while remote jobs are allowed. The ping (MF-11, 0.3.21): every wake
|
||||
/// request names this machine (its id8, no secret), the app version and the last job it ran, so the relay can show a
|
||||
/// machine whose job channel has gone quiet ("silent since <time>, last job <name>") the moment 15 minutes pass with no
|
||||
/// poll; a dead app polls nothing, and before this nothing on the console said so until the next upload gap was noticed.
|
||||
pub struct WakeCtl {
|
||||
on: AtomicBool,
|
||||
machine: String,
|
||||
last_job: Mutex<String>,
|
||||
}
|
||||
|
||||
impl WakeCtl {
|
||||
pub fn new(on: bool, machine: &str) -> WakeCtl {
|
||||
WakeCtl { on: AtomicBool::new(on), machine: machine.to_string(), last_job: Mutex::new(String::new()) }
|
||||
}
|
||||
pub fn set_last_job(&self, id: &str) {
|
||||
*self.last_job.lock().unwrap() = id.to_string();
|
||||
}
|
||||
/// The query fragment of the ping: machine=<id8>&v=<version>[&job=<id>]; values are the app's own, URL-safe by shape.
|
||||
pub fn ping(&self) -> String {
|
||||
ping_query(&self.machine, crate::engine::VERSION, &self.last_job.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
/// machine and job ids as the relay reads them: id8 is 8 hex; a job id is the publisher's `[\w.-]` name; anything else
|
||||
/// is dropped from the query rather than escaped (the relay clips and validates on its side too).
|
||||
pub fn ping_query(machine: &str, version: &str, last_job: &str) -> String {
|
||||
let safe = |s: &str, max: usize| -> String { s.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')).take(max).collect() };
|
||||
let m = safe(machine, 16);
|
||||
if m.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut q = format!("machine={m}&v={}", safe(version, 32));
|
||||
let j = safe(last_job, 80);
|
||||
if !j.is_empty() {
|
||||
q.push_str(&format!("&job={j}"));
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// The stamp logic, free of I/O for the tests.
|
||||
|
|
@ -829,17 +931,24 @@ fn wake_url_of(env: Option<String>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn wake_query(url: &str, since: &str) -> String {
|
||||
if since.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{url}{}since={since}", if url.contains('?') { '&' } else { '?' })
|
||||
fn wake_query(url: &str, since: &str, ping: &str) -> String {
|
||||
let mut out = url.to_string();
|
||||
let mut sep = if url.contains('?') { '&' } else { '?' };
|
||||
if !since.is_empty() {
|
||||
out.push(sep);
|
||||
out.push_str(&format!("since={since}"));
|
||||
sep = '&';
|
||||
}
|
||||
if !ping.is_empty() {
|
||||
out.push(sep);
|
||||
out.push_str(ping);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One long-poll. Ok carries the relay's stamp (empty when it holds none).
|
||||
fn wake_request(url: &str, since: &str) -> Result<String, String> {
|
||||
let full = wake_query(url, since);
|
||||
fn wake_request(url: &str, since: &str, ping: &str) -> Result<String, String> {
|
||||
let full = wake_query(url, since, ping);
|
||||
let max_time = (WAKE_HOLD_S + 13).to_string();
|
||||
let (code, out) = run_capture(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", &max_time, &full]), Duration::from_secs(WAKE_HOLD_S + 20));
|
||||
if code != Some(0) {
|
||||
|
|
@ -860,7 +969,7 @@ fn wake_loop(shared: Arc<Shared>, url: String, ctl: Arc<WakeCtl>) {
|
|||
continue;
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
match wake_request(&url, &st.stamp) {
|
||||
match wake_request(&url, &st.stamp, &ctl.ping()) {
|
||||
Ok(stamp) => {
|
||||
let (r, recovered) = st.reply(&stamp);
|
||||
if recovered {
|
||||
|
|
@ -1316,6 +1425,50 @@ fn elevated_wrapper(env_lines: &str, script: &str, out_file: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// The choices a `cards` job asks for, matched to the machine's present cards (key exact, or the key with the device
|
||||
/// index left out: `vendor::name`); missing keys are returned for the refusal. A field the job leaves out keeps the
|
||||
/// card's current value.
|
||||
pub fn cards_job_choices(job: &Job, live: &[(String, bool, u32)]) -> (Vec<crate::engine::CardChoice>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
for c in list {
|
||||
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||||
let found = live.iter().find(|(k, _, _)| *k == key).or_else(|| {
|
||||
let parts: Vec<&str> = key.splitn(3, ':').collect();
|
||||
live.iter().find(|(k, _, _)| { let lp: Vec<&str> = k.splitn(3, ':').collect(); parts.len() == 3 && lp.len() == 3 && lp[0] == parts[0] && lp[2] == parts[2] && (parts[1].is_empty() || parts[1] == lp[1]) })
|
||||
});
|
||||
match found {
|
||||
Some((k, enabled, identities)) => out.push(crate::engine::CardChoice {
|
||||
key: k.clone(),
|
||||
enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(*enabled),
|
||||
identities: c.get("identities").and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(*identities),
|
||||
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
}),
|
||||
None => missing.push(key),
|
||||
}
|
||||
}
|
||||
(out, missing)
|
||||
}
|
||||
|
||||
/// The hold rule (MF-6, PC 1, 7 October 2026: a read-only job that followed a --stop-miners job kept the cards off
|
||||
/// for its whole run): a hold belongs to the job that took it and releases the moment that job is no longer the
|
||||
/// running one, whatever runs next, or when the owner's own cap has passed. Returns the reason to release, or None.
|
||||
pub fn hold_release(owner: Option<&str>, active: Option<&str>, held_s: f64, cap_s: f64) -> Option<&'static str> {
|
||||
match owner {
|
||||
None => Some("no job owns the hold"),
|
||||
Some(o) => {
|
||||
if active != Some(o) {
|
||||
Some("the job that took the hold is no longer running")
|
||||
} else if held_s >= cap_s {
|
||||
Some("the hold passed the job's own cap")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_ran(ran: Ran, what: &str) -> Result<Done, String> {
|
||||
match ran.code {
|
||||
Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }),
|
||||
|
|
@ -1434,6 +1587,23 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// cards_leave_off (7 October 2026): the restore entries keep their identities and cap and carry enabled=false,
|
||||
/// so the job's exit leaves the card off through the same path; without the param they keep their own flag.
|
||||
#[test]
|
||||
fn cards_leave_off_restores_the_card_as_off_with_its_settings_kept() {
|
||||
let live: Vec<LiveCard> = vec![("other:Intel(R) Arc(TM) B580 Graphics".into(), true, 8, 0, true), ("nvidia:NVIDIA GeForce RTX 5090".into(), true, 2, 80, true)];
|
||||
let (off, restore) = cards_off_choices(&["other:Intel(R) Arc(TM) B580 Graphics".to_string()], &live);
|
||||
assert_eq!(off.len(), 1);
|
||||
assert!(restore[0].enabled, "the plain restore puts the card back on");
|
||||
let left = leave_off(restore.clone());
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!((left[0].key.as_str(), left[0].enabled, left[0].identities, left[0].power_pct), ("other:Intel(R) Arc(TM) B580 Graphics", false, 8, restore[0].power_pct));
|
||||
// the job's param decides, through the same hold
|
||||
let j = jobs::parse(r#"{"jobs":[{"id":"x","kind":"run","expires_at":"2099-01-01T00:00:00Z","target":{"machine_ids":["ae432dc7"]},"params":{"script":"ls","cards_off":["other:Intel(R) Arc(TM) B580 Graphics"],"cards_leave_off":true}}]}"#).unwrap().jobs.remove(0);
|
||||
assert!(j.bool_param("cards_leave_off"));
|
||||
assert_eq!(j.list_param("cards_off"), vec!["other:Intel(R) Arc(TM) B580 Graphics".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_off_matches_keys_with_and_without_the_device_index_and_restores_exactly() {
|
||||
// PC 1, 6 October 2026: settings.json holds amd:1:gfx1201 and amd:3:gfx1201, the live state's key is amd:gfx1201
|
||||
|
|
@ -1566,9 +1736,16 @@ mod tests {
|
|||
assert_eq!(wake_url_of(None), WAKE_URL);
|
||||
assert_eq!(wake_url_of(Some(String::new())), "");
|
||||
assert_eq!(wake_url_of(Some(" http://127.0.0.1:4180/wake ".into())), "http://127.0.0.1:4180/wake");
|
||||
assert_eq!(wake_query("https://r/wake", ""), "https://r/wake");
|
||||
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5"), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
|
||||
assert_eq!(wake_query("https://r/api/wake?x=1", "S"), "https://r/api/wake?x=1&since=S");
|
||||
assert_eq!(wake_query("https://r/wake", "", ""), "https://r/wake");
|
||||
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5", ""), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
|
||||
assert_eq!(wake_query("https://r/api/wake?x=1", "S", ""), "https://r/api/wake?x=1&since=S");
|
||||
// the ping (MF-11): the machine, the version and the last job ride on every wake request
|
||||
assert_eq!(wake_query("https://r/wake", "", "machine=1ccfe586&v=0.3.21"), "https://r/wake?machine=1ccfe586&v=0.3.21");
|
||||
assert_eq!(wake_query("https://r/wake", "S", "machine=1ccfe586&v=0.3.21&job=update-now-0319"), "https://r/wake?since=S&machine=1ccfe586&v=0.3.21&job=update-now-0319");
|
||||
assert_eq!(ping_query("1ccfe586", "0.3.21", ""), "machine=1ccfe586&v=0.3.21");
|
||||
assert_eq!(ping_query("1ccfe586", "0.3.21", "update-now-0319-1ccfe586"), "machine=1ccfe586&v=0.3.21&job=update-now-0319-1ccfe586");
|
||||
assert_eq!(ping_query("", "0.3.21", "x"), "", "no machine, no ping");
|
||||
assert_eq!(ping_query("1ccfe586", "0.3.21", "a b&c=d"), "machine=1ccfe586&v=0.3.21&job=abcd", "only the safe characters travel");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2058,3 +2235,23 @@ fn account_warning(ctx: &str) -> String {
|
|||
fn short(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n { s.to_string() } else { format!("{}...", s.chars().take(n).collect::<String>()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hold_tests {
|
||||
use super::hold_release;
|
||||
|
||||
/// MF-6 (PC 1, 7 October 2026): a --stop-miners job's hold outlived it into a read-only watch job for three minutes.
|
||||
#[test]
|
||||
fn a_hold_belongs_to_the_job_that_took_it() {
|
||||
// the owner is still running, under its cap: the hold stays
|
||||
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 30.0, 3600.0), None);
|
||||
// another job runs now: released at once, whatever that job is
|
||||
assert_eq!(hold_release(Some("job-a"), Some("job-b"), 30.0, 3600.0), Some("the job that took the hold is no longer running"));
|
||||
// no job runs: released
|
||||
assert_eq!(hold_release(Some("job-a"), None, 30.0, 3600.0), Some("the job that took the hold is no longer running"));
|
||||
// the owner's own cap passed: released and logged
|
||||
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 3601.0, 3600.0), Some("the hold passed the job's own cap"));
|
||||
// a hold with no owner (an older engine state) never sticks
|
||||
assert_eq!(hold_release(None, Some("job-b"), 1.0, 3600.0), Some("no job owns the hold"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@
|
|||
//!
|
||||
//! Kinds and their params:
|
||||
//! run script (the body), shell powershell|bash (default per platform), elevated, stop_miners_first,
|
||||
//! timeout_minutes (default 60, at most 600)
|
||||
//! timeout_minutes (default 60, at most 600), cards_off [keys] (the runner switches them off before
|
||||
//! the script and restores them on any exit), cards_leave_off (with cards_off: restored as OFF, so
|
||||
//! the card stays off and persisted after the job; 7 October 2026, the Arc on PC 1)
|
||||
//! fetch url (https), sha256, size, to (file name), dir jobs|prove|packs|updates (default jobs, which is
|
||||
//! <app data>/app/jobs/<id>/), extract (tar -xf into the dir), fresh (empty extract_dir first), extract_dir
|
||||
//! collect globs ["logs/app-*.log", ...] relative to the app data root (* and ? per path component), command
|
||||
|
|
@ -59,7 +61,7 @@ pub const JOBS_FILE: &str = "igneum-jobs.json";
|
|||
/// the signature is over the bytes of `file`, so the same key and the same signer sign both forms.
|
||||
pub const JOBS_SIGNED_FILE: &str = "igneum-jobs.signed.json";
|
||||
pub const JOBS_SIGNED_FORMAT: &str = "igneum-jobs-signed-1";
|
||||
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build"];
|
||||
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build", "cards"];
|
||||
/// Requirements the engine knows how to probe (src/jobrun.rs). An unknown requirement is never satisfied.
|
||||
pub const KNOWN_REQUIRES: &[&str] = &["wsl", "wsl-prover", "nvidia"];
|
||||
/// Named folders a `fetch` may write into, all under the app data root.
|
||||
|
|
@ -418,6 +420,33 @@ pub fn validate_params(job: &Job) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
"update-now" => {}
|
||||
"cards" => {
|
||||
// the signed `cards` kind (7 October 2026): per-card enabled and identities, applied by the app through
|
||||
// its own card path and persisted; it closes the exception of a one-off script POSTing /api/cards
|
||||
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
if list.is_empty() {
|
||||
return Err("cards: params.cards is empty (a list of {key, enabled, identities})".into());
|
||||
}
|
||||
for c in &list {
|
||||
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if key.trim().is_empty() || !key.contains(':') {
|
||||
return Err(format!("cards: key '{key}' is not a card key (vendor:device:name)"));
|
||||
}
|
||||
if c.get("enabled").map(|v| !v.is_boolean()).unwrap_or(false) {
|
||||
return Err(format!("cards: {key}: enabled must be true or false"));
|
||||
}
|
||||
if let Some(n) = c.get("identities") {
|
||||
if !n.as_u64().map(|n| (1..=64).contains(&n)).unwrap_or(false) {
|
||||
return Err(format!("cards: {key}: identities must be 1 to 64"));
|
||||
}
|
||||
}
|
||||
if let Some(n) = c.get("power_pct") {
|
||||
if !n.as_u64().map(|n| (50..=100).contains(&n)).unwrap_or(false) {
|
||||
return Err(format!("cards: {key}: power_pct must be 50 to 100"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"shard-benchmark" => {
|
||||
let url = job.str_param("zip_url");
|
||||
if !url.is_empty() && !https_ok(&url) {
|
||||
|
|
@ -824,6 +853,10 @@ mod tests {
|
|||
assert!(j("restart", r#"{"what":"everything"}"#).unwrap_err().contains("restart"));
|
||||
assert!(j("restart", r#"{"what":"miners"}"#).is_ok());
|
||||
assert!(j("update-now", r#"{}"#).is_ok());
|
||||
assert!(j("cards", r#"{}"#).unwrap_err().contains("cards"));
|
||||
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:NVIDIA GeForce RTX 5090","enabled":true,"identities":8}]}"#).is_ok());
|
||||
assert!(j("cards", r#"{"cards":[{"key":"5090","enabled":true}]}"#).unwrap_err().contains("card key"));
|
||||
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:x","identities":65}]}"#).unwrap_err().contains("1 to 64"));
|
||||
assert!(j("shard-benchmark", r#"{}"#).unwrap_err().contains("sha256"));
|
||||
assert!(j("shard-benchmark", &format!(r#"{{"sha256":"{}","fixtures":["../x"]}}"#, "b".repeat(64))).unwrap_err().contains("fixture"));
|
||||
assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell"));
|
||||
|
|
|
|||
735
app/igneum-app/src/ladder.rs
Normal file
735
app/igneum-app/src/ladder.rs
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
//! The miner's first month, as this machine records it (miner-ui-5, mission item 6, 7 October 2026). The ladder's
|
||||
//! rungs are chain facts (src/chainfacts.rs reads them from `getFinalityWeights` and `getFinalityCheckpoints`); this
|
||||
//! module keeps what only this machine knows: when its first block came and on which card, the blocks it found per day
|
||||
//! (the days-mined ring), every checkpoint its miners signed and which of those locked (the signing streak), the shards
|
||||
//! its cards were paid for, and the block card to show (the first block, every 100th, 1,000th and 10,000th, the first
|
||||
//! on a new card). Persisted as `<app dir>/ladder.json`; pure, no clock of its own (every call takes `now`).
|
||||
//!
|
||||
//! A block is matched to its hash by the nonce: the miner prints `ACCEPTED block nonce=0x..` for ours, the node prints
|
||||
//! `PoW accepted <hash> by .. (daa N, .., nonce 0x..)` for every block it validates, ours and relayed alike, so the
|
||||
//! nonce is the join and nothing else is.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const DAY_S: f64 = 86_400.0;
|
||||
/// days kept in the ring (the 30-day window plus a month of slack for the view)
|
||||
const KEEP_DAYS: u64 = 60;
|
||||
const KEEP_BLOCKS: usize = 30;
|
||||
const KEEP_SHARDS: usize = 50;
|
||||
const KEEP_PENDING: usize = 400;
|
||||
const KEEP_SIGNED: usize = 400;
|
||||
/// the block counts that raise a card (then every 10,000th)
|
||||
pub const MILESTONES: [u64; 4] = [1, 100, 1_000, 10_000];
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DayBlocks {
|
||||
/// unix day number (unix seconds / 86,400, UTC)
|
||||
pub day: u64,
|
||||
pub blocks: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BlockFound {
|
||||
pub hash: String,
|
||||
pub daa: u64,
|
||||
pub nonce: String,
|
||||
pub at: f64,
|
||||
pub card: String,
|
||||
pub card_name: String,
|
||||
/// this machine's lifetime count at that block (1 = the first)
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ShardPaid {
|
||||
pub block: u64,
|
||||
pub shard: u64,
|
||||
/// wei as a decimal string (u128 does not survive every JSON reader)
|
||||
pub wei: String,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Milestone {
|
||||
/// first | hundred | thousand | ten_thousand | card
|
||||
pub kind: String,
|
||||
pub count: u64,
|
||||
pub card: String,
|
||||
pub card_name: String,
|
||||
pub hash: String,
|
||||
pub daa: u64,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Ladder {
|
||||
#[serde(default)]
|
||||
pub first_block_at: f64,
|
||||
#[serde(default)]
|
||||
pub first_block_card: String,
|
||||
#[serde(default)]
|
||||
pub first_block_hash: String,
|
||||
#[serde(default)]
|
||||
pub first_block_daa: u64,
|
||||
#[serde(default)]
|
||||
pub days: Vec<DayBlocks>,
|
||||
/// card key -> unix s of its first block
|
||||
#[serde(default)]
|
||||
pub cards_first: BTreeMap<String, f64>,
|
||||
#[serde(default)]
|
||||
pub blocks: Vec<BlockFound>,
|
||||
#[serde(default)]
|
||||
pub votes_signed: u64,
|
||||
#[serde(default)]
|
||||
pub last_vote_index: u64,
|
||||
#[serde(default)]
|
||||
pub last_vote_at: f64,
|
||||
/// checkpoints signed and not yet seen locked
|
||||
#[serde(default)]
|
||||
pub signed_open: Vec<u64>,
|
||||
#[serde(default)]
|
||||
pub last_signed_locked: u64,
|
||||
#[serde(default)]
|
||||
pub signed_locked_count: u64,
|
||||
/// when the unbroken run of votes began (0 = no vote yet)
|
||||
#[serde(default)]
|
||||
pub streak_since: f64,
|
||||
#[serde(default)]
|
||||
pub shards: Vec<ShardPaid>,
|
||||
#[serde(default)]
|
||||
pub milestone: Option<Milestone>,
|
||||
/// the first-hour timeline's marks: when this install's node first synced and a card first mined (0 = not yet)
|
||||
#[serde(default)]
|
||||
pub first_synced_at: f64,
|
||||
#[serde(default)]
|
||||
pub first_mining_at: f64,
|
||||
/// node-accepted blocks waiting for the miner's own ACCEPTED line (nonce -> hash, daa), and the reverse case
|
||||
#[serde(default)]
|
||||
pending: Vec<(String, String, u64)>,
|
||||
/// first-block-21 (the project lead, 7 October 2026: "the 'you got your first block' situation only shows for the miner's
|
||||
/// first ever block"; main's reading: SEEN once): set when a window has shown the first-block card (the page
|
||||
/// reports it through POST /api/card/seen on display or dismiss), never when it is raised. The card is raised when
|
||||
/// the lifetime count the engine persists (settings.json accepted_total) crosses from 0 to 1 and waits, across
|
||||
/// restarts and updates, until a window shows it: a block found overnight or across an auto-update greets the
|
||||
/// miner the first time they open the app, and after that never again. Kept across restarts, updates and a kept
|
||||
/// data directory; never cleared by a key changing identities or a card being swapped (the count is the
|
||||
/// machine's, not a key's or a card's). A count that starts over on a record carrying the flag (settings.json
|
||||
/// lost, the ladder kept) raises no first card again; a 0.3.20 record (schema 0, no flag) whose first block already
|
||||
/// came (count 1 or more, or first_block_at set) takes the flag at `start_run`, since 0.3.20 showed it every run.
|
||||
#[serde(default)]
|
||||
pub first_block_shown: bool,
|
||||
/// a window has shown the current `milestone` (set with it by `card_seen`); a seen card is dropped at the next
|
||||
/// `start_run`, an unseen one waits for its window
|
||||
#[serde(default)]
|
||||
pub milestone_seen: bool,
|
||||
/// the record's shape: 0 = written by 0.3.20 (no first-block flag), 1 = first-block-21
|
||||
#[serde(default)]
|
||||
pub schema: u32,
|
||||
}
|
||||
|
||||
/// What the dashboard reads (api/state.ladder): the record above summarised at `now`.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct LadderState {
|
||||
pub first_block_at: f64,
|
||||
pub first_block_card: String,
|
||||
pub first_block_hash: String,
|
||||
pub first_block_daa: u64,
|
||||
/// distinct UTC days with at least one block in the last 30 days
|
||||
pub days_mined_30: u64,
|
||||
pub blocks_30d: u64,
|
||||
pub blocks_today: u64,
|
||||
pub days: Vec<DayBlocks>,
|
||||
pub cards_first: BTreeMap<String, f64>,
|
||||
pub blocks: Vec<BlockFound>,
|
||||
pub votes_signed: u64,
|
||||
pub last_vote_index: u64,
|
||||
pub last_vote_at: f64,
|
||||
pub last_signed_locked: u64,
|
||||
pub signed_locked_count: u64,
|
||||
/// seconds of unbroken signing (0 = none)
|
||||
pub streak_s: f64,
|
||||
pub shards: Vec<ShardPaid>,
|
||||
pub milestone: Option<Milestone>,
|
||||
pub first_synced_at: f64,
|
||||
pub first_mining_at: f64,
|
||||
/// first-block-21: the first-block card has been raised once on this machine (see `Ladder::first_block_shown`)
|
||||
pub first_block_shown: bool,
|
||||
}
|
||||
|
||||
pub fn day_of(unix: f64) -> u64 {
|
||||
(unix.max(0.0) / DAY_S).floor() as u64
|
||||
}
|
||||
|
||||
impl Ladder {
|
||||
pub fn load(path: &std::path::Path) -> Ladder {
|
||||
std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
|
||||
}
|
||||
pub fn save(&self, path: &std::path::Path) {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
let mut me = self.clone();
|
||||
me.schema = 1;
|
||||
if let Ok(t) = serde_json::to_string_pretty(&me) {
|
||||
let _ = std::fs::write(path, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's run begins over this record (first-block-21, 7 October 2026). `accepted_total` is the lifetime
|
||||
/// count settings.json holds. A block card a window has shown (`milestone_seen`) is dropped here, so a restart
|
||||
/// or an update never shows "your first block" (or block 100) a second time; a card no window has shown yet
|
||||
/// waits (found overnight, or across an auto-update restart). A 0.3.20 record (schema 0) carries no flag and
|
||||
/// showed its card on every run, so one whose first block already came (the count at 1 or more, or
|
||||
/// first_block_at set) takes the flag now and its card is dropped. Returns true when the record changed and
|
||||
/// wants saving.
|
||||
pub fn start_run(&mut self, accepted_total: u64) -> bool {
|
||||
let mut dirty = false;
|
||||
if self.schema == 0 {
|
||||
if accepted_total >= 1 || self.first_block_at > 0.0 {
|
||||
self.first_block_shown = true;
|
||||
self.milestone = None;
|
||||
}
|
||||
self.schema = 1;
|
||||
dirty = true;
|
||||
}
|
||||
if self.milestone.is_some() && self.milestone_seen {
|
||||
self.milestone = None;
|
||||
self.milestone_seen = false;
|
||||
dirty = true;
|
||||
}
|
||||
dirty
|
||||
}
|
||||
|
||||
/// A window showed (or the user dismissed) the card for the milestone at `count` and `at` (first-block-21): the
|
||||
/// card is spent, and a first-block card sets the lifetime flag. Returns true when the record changed.
|
||||
pub fn card_seen(&mut self, count: u64, at: f64) -> bool {
|
||||
let m = match self.milestone.as_ref() {
|
||||
Some(m) if m.count == count && (m.at - at).abs() < 1.0 => m,
|
||||
_ => return false,
|
||||
};
|
||||
let mut dirty = false;
|
||||
if !self.milestone_seen {
|
||||
self.milestone_seen = true;
|
||||
dirty = true;
|
||||
}
|
||||
if m.kind == "first" && !self.first_block_shown {
|
||||
self.first_block_shown = true;
|
||||
dirty = true;
|
||||
}
|
||||
dirty
|
||||
}
|
||||
|
||||
/// The node validated a block (its own log line): remembered by nonce until the miner's line claims it.
|
||||
pub fn on_node_accepted(&mut self, hash: &str, daa: u64, nonce: &str) {
|
||||
let nonce = norm_nonce(nonce);
|
||||
if nonce.is_empty() || hash.is_empty() {
|
||||
return;
|
||||
}
|
||||
// a block of ours whose miner line came first: fill the hash in now
|
||||
if let Some(b) = self.blocks.iter_mut().find(|b| b.hash.is_empty() && b.nonce == nonce) {
|
||||
b.hash = hash.to_string();
|
||||
b.daa = daa;
|
||||
if self.first_block_hash.is_empty() && self.first_block_at == b.at {
|
||||
self.first_block_hash = hash.to_string();
|
||||
self.first_block_daa = daa;
|
||||
}
|
||||
if let Some(m) = self.milestone.as_mut() {
|
||||
if m.hash.is_empty() && m.at == b.at {
|
||||
m.hash = hash.to_string();
|
||||
m.daa = daa;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.pending.push((nonce, hash.to_string(), daa));
|
||||
if self.pending.len() > KEEP_PENDING {
|
||||
let n = self.pending.len() - KEEP_PENDING;
|
||||
self.pending.drain(0..n);
|
||||
}
|
||||
}
|
||||
|
||||
/// The miner's own ACCEPTED line: `total` is this machine's lifetime count including this block. Returns the
|
||||
/// milestone this block raised, if any.
|
||||
pub fn on_block(&mut self, now: f64, card: &str, card_name: &str, nonce: &str, total: u64) -> Option<Milestone> {
|
||||
let nonce = norm_nonce(nonce);
|
||||
let (hash, daa) = match self.pending.iter().position(|p| !nonce.is_empty() && p.0 == nonce) {
|
||||
Some(i) => {
|
||||
let p = self.pending.remove(i);
|
||||
(p.1, p.2)
|
||||
}
|
||||
None => (String::new(), 0),
|
||||
};
|
||||
let day = day_of(now);
|
||||
match self.days.iter_mut().find(|d| d.day == day) {
|
||||
Some(d) => d.blocks += 1,
|
||||
None => self.days.push(DayBlocks { day, blocks: 1 }),
|
||||
}
|
||||
self.days.sort_by_key(|d| d.day);
|
||||
if self.days.len() > KEEP_DAYS as usize {
|
||||
let n = self.days.len() - KEEP_DAYS as usize;
|
||||
self.days.drain(0..n);
|
||||
}
|
||||
// the first-block card: once in the app's life, when the persisted lifetime count crosses from 0 to 1 on a
|
||||
// record with no block and no flag (first-block-21); a count that starts over raises nothing here
|
||||
let first = total == 1 && !self.first_block_shown && self.first_block_at == 0.0;
|
||||
if self.first_block_at == 0.0 {
|
||||
self.first_block_at = now;
|
||||
self.first_block_card = card.to_string();
|
||||
self.first_block_hash = hash.clone();
|
||||
self.first_block_daa = daa;
|
||||
}
|
||||
let new_card = !card.is_empty() && !self.cards_first.contains_key(card);
|
||||
if new_card {
|
||||
self.cards_first.insert(card.to_string(), now);
|
||||
}
|
||||
self.blocks.insert(0, BlockFound { hash: hash.clone(), daa, nonce, at: now, card: card.to_string(), card_name: card_name.to_string(), count: total });
|
||||
self.blocks.truncate(KEEP_BLOCKS);
|
||||
let kind = if first { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card && total > 1 { "card" } else { "" };
|
||||
if kind.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let m = Milestone { kind: kind.into(), count: total, card: card.into(), card_name: card_name.into(), hash, daa, at: now };
|
||||
self.milestone = Some(m.clone());
|
||||
self.milestone_seen = false;
|
||||
self.schema = 1;
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A `VOTE index=N` line from a miner: the streak holds while votes keep coming inside `gap_s`.
|
||||
pub fn on_vote(&mut self, now: f64, index: u64, gap_s: f64) {
|
||||
self.votes_signed += 1;
|
||||
if self.streak_since == 0.0 || (self.last_vote_at > 0.0 && now - self.last_vote_at > gap_s) {
|
||||
self.streak_since = now;
|
||||
}
|
||||
self.last_vote_at = now;
|
||||
if index > self.last_vote_index {
|
||||
self.last_vote_index = index;
|
||||
}
|
||||
if index > 0 && !self.signed_open.contains(&index) {
|
||||
self.signed_open.push(index);
|
||||
if self.signed_open.len() > KEEP_SIGNED {
|
||||
let n = self.signed_open.len() - KEEP_SIGNED;
|
||||
self.signed_open.drain(0..n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LOCK checkpoint N` line: every signed index at or under N is in a locked certificate now.
|
||||
pub fn on_lock(&mut self, index: u64) {
|
||||
let (locked, open): (Vec<u64>, Vec<u64>) = self.signed_open.iter().partition(|&&i| i <= index);
|
||||
if let Some(&top) = locked.iter().max() {
|
||||
if top > self.last_signed_locked {
|
||||
self.last_signed_locked = top;
|
||||
}
|
||||
self.signed_locked_count += locked.len() as u64;
|
||||
}
|
||||
self.signed_open = open;
|
||||
}
|
||||
|
||||
/// The timeline's first-time marks ("synced", "mining"): set once, kept. Returns true when it was new.
|
||||
pub fn mark(&mut self, what: &str, now: f64) -> bool {
|
||||
let slot = match what { "synced" => &mut self.first_synced_at, "mining" => &mut self.first_mining_at, _ => return false };
|
||||
if *slot > 0.0 { return false; }
|
||||
*slot = now;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn on_shard_paid(&mut self, now: f64, block: u64, shard: u64, wei: u128) {
|
||||
if self.shards.iter().any(|s| s.block == block && s.shard == shard) {
|
||||
return;
|
||||
}
|
||||
self.shards.insert(0, ShardPaid { block, shard, wei: wei.to_string(), at: now });
|
||||
self.shards.truncate(KEEP_SHARDS);
|
||||
}
|
||||
|
||||
/// Seconds of unbroken signing at `now` (0 when the last vote is older than `gap_s`).
|
||||
pub fn streak_s(&self, now: f64, gap_s: f64) -> f64 {
|
||||
if self.streak_since == 0.0 || self.last_vote_at == 0.0 || now - self.last_vote_at > gap_s {
|
||||
return 0.0;
|
||||
}
|
||||
(now - self.streak_since).max(0.0)
|
||||
}
|
||||
|
||||
fn blocks_since(&self, from: f64) -> u64 {
|
||||
// the day ring is daily; the finer windows read the block list and fall back to the ring
|
||||
self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > from).map(|d| d.blocks).sum()
|
||||
}
|
||||
|
||||
pub fn state(&self, now: f64, gap_s: f64) -> LadderState {
|
||||
let today = day_of(now);
|
||||
let window_from = now - 30.0 * DAY_S;
|
||||
let days_mined_30 = self.days.iter().filter(|d| d.blocks > 0 && (d.day as f64 + 1.0) * DAY_S > window_from).count() as u64;
|
||||
LadderState {
|
||||
first_block_at: self.first_block_at,
|
||||
first_block_card: self.first_block_card.clone(),
|
||||
first_block_hash: self.first_block_hash.clone(),
|
||||
first_block_daa: self.first_block_daa,
|
||||
days_mined_30,
|
||||
blocks_30d: self.blocks_since(window_from),
|
||||
blocks_today: self.days.iter().find(|d| d.day == today).map(|d| d.blocks).unwrap_or(0),
|
||||
days: self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > window_from).cloned().collect(),
|
||||
cards_first: self.cards_first.clone(),
|
||||
blocks: self.blocks.iter().take(10).cloned().collect(),
|
||||
votes_signed: self.votes_signed,
|
||||
last_vote_index: self.last_vote_index,
|
||||
last_vote_at: self.last_vote_at,
|
||||
last_signed_locked: self.last_signed_locked,
|
||||
signed_locked_count: self.signed_locked_count,
|
||||
streak_s: self.streak_s(now, gap_s),
|
||||
shards: self.shards.clone(),
|
||||
milestone: self.milestone.clone(),
|
||||
first_synced_at: self.first_synced_at,
|
||||
first_mining_at: self.first_mining_at,
|
||||
first_block_shown: self.first_block_shown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Activity line a milestone raises (the dashboard's EVENT_MAP reads these shapes).
|
||||
pub fn milestone_event(m: &Milestone) -> String {
|
||||
match m.kind.as_str() {
|
||||
"first" => format!("first block: found by {} (block card)", m.card_name),
|
||||
"hundred" => format!("block 100 of this machine: found by {} (block card)", m.card_name),
|
||||
"thousand" => format!("block 1,000 of this machine: found by {} (block card)", m.card_name),
|
||||
"ten_thousand" => format!("block {} of this machine: found by {} (block card)", with_commas(m.count), m.card_name),
|
||||
_ => format!("first block on {} (block card)", m.card_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_commas(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i) % 3 == 0 { out.push(','); }
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn norm_nonce(n: &str) -> String {
|
||||
let t = n.trim().trim_start_matches("0x").trim_start_matches('0').to_ascii_lowercase();
|
||||
if t.is_empty() && !n.trim().is_empty() { "0".into() } else { t }
|
||||
}
|
||||
|
||||
/// `PoW accepted <hash> by <engine> (daa N, ..., nonce 0x..)` -> (hash, daa, nonce)
|
||||
pub fn parse_node_accepted(text: &str) -> Option<(String, u64, String)> {
|
||||
let rest = text.split("PoW accepted ").nth(1)?;
|
||||
let hash = rest.split_whitespace().next()?.to_string();
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let daa = rest.split("(daa ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::<u64>().ok()?;
|
||||
let nonce = rest.split("nonce ").nth(1)?.trim_end_matches(')').split_whitespace().next()?.to_string();
|
||||
Some((hash, daa, nonce))
|
||||
}
|
||||
|
||||
/// `... ACCEPTED block nonce=0x.. (...)` -> the nonce
|
||||
pub fn parse_miner_accepted(text: &str) -> Option<String> {
|
||||
let rest = text.split("ACCEPTED block nonce=").nth(1)?;
|
||||
Some(rest.split_whitespace().next()?.to_string())
|
||||
}
|
||||
|
||||
/// `... VOTE index=N ...` -> N
|
||||
pub fn parse_vote_index(text: &str) -> Option<u64> {
|
||||
text.split("VOTE index=").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
const NODE: &str = "2026-10-04 20:08:38.486+00:00 [INFO ] PoW accepted 28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce by igneum-lottery-v2-bound (daa 38637, epoch seed c8b574fbbbd6ba93b34f8c9a81042b6970b140ffb24219e381d82b653673ee81, day 20730, nonce 0x267dd27200a91c80)";
|
||||
const MINER: &str = "1791140918.500 ACCEPTED block nonce=0x267dd27200a91c80 (gpu worker, cpu re-check ok, identity mac-01234567-1)";
|
||||
|
||||
#[test]
|
||||
fn the_node_and_miner_lines_parse() {
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
assert_eq!(h, "28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce");
|
||||
assert_eq!(daa, 38637);
|
||||
assert_eq!(n, "0x267dd27200a91c80");
|
||||
assert_eq!(parse_miner_accepted(MINER).unwrap(), "0x267dd27200a91c80");
|
||||
assert_eq!(parse_vote_index("1791140918.500 VOTE index=1240 checkpoint=abcd signed").unwrap(), 1240);
|
||||
assert!(parse_node_accepted("PoW rejected 00 by x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_block_is_matched_to_its_hash_by_nonce_and_raises_the_first_card() {
|
||||
let mut l = Ladder::default();
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
l.on_node_accepted(&h, daa, &n);
|
||||
// a relayed block from another miner sits in pending too and never matches
|
||||
l.on_node_accepted("ef21a8454b2f3fcaf6c32a8b9c959df936770edfdffff61c7198989d9431ea4b", 38643, "0x4bc93aba007eabaa");
|
||||
let m = l.on_block(1_791_140_918.5, "apple::Apple M5 Max", "Apple M5 Max", &parse_miner_accepted(MINER).unwrap(), 1).unwrap();
|
||||
assert_eq!(m.kind, "first");
|
||||
assert_eq!(m.hash, h);
|
||||
assert_eq!(m.daa, 38637);
|
||||
assert_eq!(l.first_block_hash, h);
|
||||
assert_eq!(l.first_block_daa, 38637);
|
||||
assert_eq!(l.first_block_card, "apple::Apple M5 Max");
|
||||
assert_eq!(l.blocks.len(), 1);
|
||||
assert_eq!(l.pending.len(), 1, "the relayed block stays unmatched");
|
||||
let s = l.state(1_791_140_920.0, 600.0);
|
||||
assert_eq!(s.days_mined_30, 1);
|
||||
assert_eq!(s.blocks_30d, 1);
|
||||
assert_eq!(s.blocks_today, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_miner_line_before_the_node_line_gets_its_hash_filled_in() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "c", "Card", "0x267dd27200a91c80", 1);
|
||||
assert_eq!(l.first_block_hash, "");
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
l.on_node_accepted(&h, daa, &n);
|
||||
assert_eq!(l.blocks[0].hash, h);
|
||||
assert_eq!(l.first_block_hash, h);
|
||||
assert_eq!(l.milestone.as_ref().unwrap().hash, h);
|
||||
assert_eq!(l.milestone.as_ref().unwrap().daa, 38637);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cards_come_at_1_100_1000_10000_and_on_a_new_card() {
|
||||
let mut l = Ladder::default();
|
||||
let mut kinds = vec![];
|
||||
for total in 1..=1_000u64 {
|
||||
if let Some(m) = l.on_block(1000.0 + total as f64, "a", "A", "", total) { kinds.push((total, m.kind)); }
|
||||
}
|
||||
assert_eq!(kinds, vec![(1, "first".to_string()), (100, "hundred".to_string()), (1000, "thousand".to_string())]);
|
||||
assert_eq!(l.on_block(5000.0, "b", "B", "", 1001).unwrap().kind, "card");
|
||||
assert!(l.on_block(5001.0, "b", "B", "", 1002).is_none());
|
||||
assert_eq!(l.on_block(5002.0, "a", "A", "", 10_000).unwrap().kind, "ten_thousand");
|
||||
assert_eq!(l.on_block(5003.0, "a", "A", "", 20_000).unwrap().kind, "ten_thousand");
|
||||
assert_eq!(l.blocks.len(), KEEP_BLOCKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_mined_counts_distinct_days_inside_the_window_only() {
|
||||
let mut l = Ladder::default();
|
||||
let now = 40.0 * DAY_S + 100.0;
|
||||
// blocks on days 5, 20, 20, 39 and 40 (today); day 5 is outside the 30-day window
|
||||
for (d, total) in [(5.0, 1), (20.0, 2), (20.0, 3), (39.0, 4), (40.0, 5)] {
|
||||
l.on_block(d * DAY_S + 50.0, "a", "A", "", total);
|
||||
}
|
||||
let s = l.state(now, 600.0);
|
||||
assert_eq!(s.days_mined_30, 3);
|
||||
assert_eq!(s.blocks_30d, 4);
|
||||
assert_eq!(s.blocks_today, 1);
|
||||
assert_eq!(s.days.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signing_streak_holds_across_votes_and_breaks_on_a_gap() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_vote(1000.0, 10, 600.0);
|
||||
l.on_vote(1030.0, 11, 600.0);
|
||||
l.on_vote(1060.0, 12, 600.0);
|
||||
assert_eq!(l.streak_s(1090.0, 600.0), 90.0);
|
||||
assert_eq!(l.votes_signed, 3);
|
||||
assert_eq!(l.last_vote_index, 12);
|
||||
// the lock at 11 puts 10 and 11 in locked certificates; 12 stays open
|
||||
l.on_lock(11);
|
||||
assert_eq!(l.last_signed_locked, 11);
|
||||
assert_eq!(l.signed_locked_count, 2);
|
||||
assert_eq!(l.signed_open, vec![12]);
|
||||
// a gap longer than the presence window ends the streak; the next vote starts a new one
|
||||
assert_eq!(l.streak_s(1700.0, 600.0), 0.0);
|
||||
l.on_vote(1700.0, 33, 600.0);
|
||||
assert_eq!(l.streak_s(1730.0, 600.0), 30.0);
|
||||
assert_eq!(l.state(1730.0, 600.0).streak_s, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_milestone_events_read_in_the_users_words() {
|
||||
let m = |kind: &str, count: u64| Milestone { kind: kind.into(), count, card: "a".into(), card_name: "RTX 4070".into(), hash: String::new(), daa: 0, at: 0.0 };
|
||||
assert_eq!(milestone_event(&m("first", 1)), "first block: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("hundred", 100)), "block 100 of this machine: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("ten_thousand", 20_000)), "block 20,000 of this machine: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("card", 7)), "first block on RTX 4070 (block card)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timeline_marks_are_set_once() {
|
||||
let mut l = Ladder::default();
|
||||
assert!(l.mark("synced", 10.0));
|
||||
assert!(!l.mark("synced", 20.0));
|
||||
assert!(l.mark("mining", 30.0));
|
||||
assert!(!l.mark("other", 40.0));
|
||||
let s = l.state(50.0, 600.0);
|
||||
assert_eq!(s.first_synced_at, 10.0);
|
||||
assert_eq!(s.first_mining_at, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_shards_are_kept_once_each_newest_first() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_shard_paid(10.0, 1_284_117, 3, 1_150_000_000_000_000_000);
|
||||
l.on_shard_paid(11.0, 1_284_117, 3, 1_150_000_000_000_000_000);
|
||||
l.on_shard_paid(12.0, 1_284_120, 0, 2_000_000_000_000_000_000);
|
||||
assert_eq!(l.shards.len(), 2);
|
||||
assert_eq!(l.shards[0].block, 1_284_120);
|
||||
assert_eq!(l.shards[1].wei, "1150000000000000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_survives_a_save_and_load() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "a", "A", "0x1", 1);
|
||||
l.on_vote(130.0, 5, 600.0);
|
||||
let dir = std::env::temp_dir().join(format!("igneum-ladder-test-{}", std::process::id()));
|
||||
let path = dir.join("ladder.json");
|
||||
l.save(&path);
|
||||
let back = Ladder::load(&path);
|
||||
assert_eq!(back.first_block_at, 100.0);
|
||||
assert_eq!(back.votes_signed, 1);
|
||||
assert_eq!(back.blocks, l.blocks);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(Ladder::load(&dir.join("missing.json")).votes_signed, 0);
|
||||
}
|
||||
|
||||
// ---- first-block-21 (the project lead, 7 October 2026): the first-block card SEEN once in the app's life ----
|
||||
|
||||
/// The record as 0.3.20 wrote it after the first block: the milestone persisted, no flag field, no schema.
|
||||
const RECORD_0320: &str = r#"{"first_block_at":1791140918.5,"first_block_card":"nvidia:0:RTX 4070","first_block_hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","first_block_daa":38637,"days":[{"day":20730,"blocks":1}],"cards_first":{"nvidia:0:RTX 4070":1791140918.5},"blocks":[{"hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"nonce":"267dd27200a91c80","at":1791140918.5,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","count":1}],"votes_signed":2,"last_vote_index":8496,"last_vote_at":1791140950.0,"signed_open":[8496],"last_signed_locked":0,"signed_locked_count":0,"streak_since":1791140930.0,"shards":[],"milestone":{"kind":"first","count":1,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"at":1791140918.5},"first_synced_at":1791140000.0,"first_mining_at":1791140100.0,"pending":[]}"#;
|
||||
|
||||
fn round_trip(l: &Ladder) -> Ladder {
|
||||
// one directory per call: the tests run in parallel and a shared path let one test's remove wipe another's file
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!("igneum-ladder-first-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::SeqCst)));
|
||||
let path = dir.join("ladder.json");
|
||||
l.save(&path);
|
||||
let back = Ladder::load(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
back
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_failed_a_second_run_with_the_lifetime_count_at_1_showed_the_first_card_again() {
|
||||
// the known-failed case: before this change a second run loaded the persisted milestone and the dashboard
|
||||
// showed "your first block" again (every restart, every update, until the dismiss in that window's storage)
|
||||
let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap();
|
||||
assert_eq!(l.milestone.as_ref().map(|m| m.kind.as_str()), Some("first"), "the fixture carries the persisted card");
|
||||
assert!(!l.first_block_shown, "the 0.3.20 record has no flag");
|
||||
assert_eq!(l.schema, 0);
|
||||
assert!(l.start_run(1), "the record changes and wants saving");
|
||||
let s = l.state(1791150000.0, 600.0);
|
||||
assert!(s.milestone.is_none(), "a card 0.3.20 showed on every run is never shown again");
|
||||
assert!(s.first_block_shown);
|
||||
assert_eq!(s.first_block_at, 1791140918.5, "the ladder's first-block facts stay");
|
||||
assert_eq!(l.schema, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_ever_block_raises_the_card_and_a_window_showing_it_sets_the_flag() {
|
||||
let mut l = Ladder::default();
|
||||
l.start_run(0);
|
||||
let m = l.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap();
|
||||
assert_eq!(m.kind, "first");
|
||||
assert!(!l.first_block_shown, "raised, not yet seen");
|
||||
assert!(!l.card_seen(7, 100.0), "another card's report changes nothing");
|
||||
assert!(l.card_seen(1, 100.0));
|
||||
assert!(l.first_block_shown);
|
||||
assert!(!l.card_seen(1, 100.0), "a second report (the dismiss) changes nothing");
|
||||
let s = l.state(101.0, 600.0);
|
||||
assert!(s.first_block_shown);
|
||||
assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the card stays on screen through this run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_block_found_with_no_window_greets_the_miner_at_the_first_open_and_never_again() {
|
||||
// main's case: the block overnight (no window), an engine restart (an auto-update), the first open shows the
|
||||
// card once; a second open (the next run) does not
|
||||
let mut night = Ladder::default();
|
||||
night.start_run(0);
|
||||
night.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
let mut morning = round_trip(&night);
|
||||
assert!(!morning.start_run(1), "an unseen card waits: nothing changes at start");
|
||||
let s = morning.state(30_000.0, 600.0);
|
||||
assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the first open shows the card");
|
||||
assert!(!s.first_block_shown);
|
||||
assert!(morning.card_seen(1, 100.0));
|
||||
assert!(morning.first_block_shown);
|
||||
let mut later = round_trip(&morning);
|
||||
assert!(later.start_run(1), "the seen card is dropped");
|
||||
assert!(later.state(60_000.0, 600.0).milestone.is_none(), "a second open shows nothing");
|
||||
assert!(later.first_block_shown);
|
||||
assert!(later.on_block(60_100.0, "a", "RTX 4070", "0x2", 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_with_the_count_at_1_shows_nothing_and_block_2_raises_no_card() {
|
||||
let mut run1 = Ladder::default();
|
||||
run1.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
assert!(run1.card_seen(1, 100.0), "the window was open");
|
||||
let mut run2 = round_trip(&run1);
|
||||
assert!(run2.first_block_shown, "the flag survives the save");
|
||||
assert_eq!(run2.schema, 1);
|
||||
assert!(run2.start_run(1), "the seen card is dropped");
|
||||
assert!(run2.state(200.0, 600.0).milestone.is_none());
|
||||
assert!(run2.on_block(300.0, "a", "RTX 4070", "0x2", 2).is_none(), "block 2 is an ordinary block");
|
||||
assert_eq!(run2.blocks[0].count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_with_the_count_at_1_takes_the_flag_from_the_older_record_shape() {
|
||||
// the new version opens a 0.3.20 record: the flag is set from the count, the card dropped, and the flag
|
||||
// survives the next save in the new shape
|
||||
let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap();
|
||||
assert!(l.start_run(1));
|
||||
assert!(l.first_block_shown);
|
||||
assert!(l.milestone.is_none());
|
||||
let mut back = round_trip(&l);
|
||||
assert!(back.first_block_shown);
|
||||
assert_eq!(back.schema, 1);
|
||||
assert!(!back.start_run(1), "a new-shape record with nothing pending changes nothing");
|
||||
assert!(back.on_block(1791150000.0, "nvidia:0:RTX 4070", "NVIDIA GeForce RTX 4070", "0x3", 2).is_none());
|
||||
// a 0.3.20 record whose count is still 0 but whose first_block_at is set (settings.json rewritten) is shown too
|
||||
let mut l2: Ladder = serde_json::from_str(RECORD_0320).unwrap();
|
||||
assert!(l2.start_run(0));
|
||||
assert!(l2.first_block_shown);
|
||||
assert!(l2.milestone.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_data_directory_with_the_count_at_0_and_the_flag_unset_shows_it_on_the_first_block() {
|
||||
// a kept directory from a machine that synced and voted but never found a block
|
||||
let mut l = Ladder::default();
|
||||
l.mark("synced", 10.0);
|
||||
l.mark("mining", 20.0);
|
||||
l.on_vote(30.0, 5, 600.0);
|
||||
let mut kept = round_trip(&l);
|
||||
assert!(!kept.start_run(0), "nothing to reconcile: no block yet");
|
||||
assert!(!kept.first_block_shown);
|
||||
let m = kept.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap();
|
||||
assert_eq!(m.kind, "first");
|
||||
assert!(kept.card_seen(1, 100.0));
|
||||
assert!(kept.first_block_shown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_count_that_starts_over_on_a_record_already_shown_raises_no_first_card() {
|
||||
// settings.json lost (the count back at 0), the ladder kept with the flag: the next block is total 1 again
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
l.card_seen(1, 100.0);
|
||||
assert!(l.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none(), "no second first-block card");
|
||||
assert!(l.on_block(300.0, "b", "RTX 5090", "0x3", 1).is_none(), "nor a new-card card at a count of 1");
|
||||
assert!(l.first_block_shown);
|
||||
assert_eq!(l.state(301.0, 600.0).blocks.len(), 3, "the blocks are still recorded");
|
||||
// the same with the card still unseen: the waiting card stays the one card, no second is raised
|
||||
let mut u = Ladder::default();
|
||||
u.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
assert!(u.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none());
|
||||
assert_eq!(u.milestone.as_ref().unwrap().at, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_2_is_an_ordinary_block_with_no_card() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
l.card_seen(1, 100.0);
|
||||
assert!(l.on_block(130.0, "a", "RTX 4070", "0x2", 2).is_none());
|
||||
let s = l.state(131.0, 600.0);
|
||||
assert_eq!(s.blocks[0].count, 2);
|
||||
assert_eq!(s.blocks_today, 2);
|
||||
assert_eq!(s.milestone.as_ref().unwrap().count, 1, "the first card of this run stays until the run ends");
|
||||
}
|
||||
}
|
||||
459
app/igneum-app/src/live.rs
Normal file
459
app/igneum-app/src/live.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module) and the Cards tab's network
|
||||
//! numbers. Two sources, one contract:
|
||||
//!
|
||||
//! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per
|
||||
//! window, passed through untouched (`shape` adds `state.you_blocks` and `state.source` = "observer"): the scene's full
|
||||
//! feed (lanes, parents, colours, the selected chain, checkpoints, proof state). This machine's blocks are NOT rewritten:
|
||||
//! the UI marks them through the scene's `mine` option (scene/feed-contract.md: `miner` is always the 8-hex key id).
|
||||
//! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s
|
||||
//! of chain and merged blocks with vote key hashes, blue scores and colours. The engine computes the observer's
|
||||
//! state fields from it (`shape_from_blocks`: miners_10m, blocks_10m, blocks_per_minute) and adds them to every
|
||||
//! observer reply as `state.node`, so the Cards tab reads network numbers that need no site; and when the observer
|
||||
//! is unreachable the node's rows stand in for the scene in the SAME JSON shape as the observer's reply
|
||||
//! (`node_only_reply`, every key of scene/feed-contract.json, null where the node cannot know; `state.source` = "node",
|
||||
//! `partial: true`: no parents, no proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the
|
||||
//! node source rests 10 minutes. The test `the_node_only_reply_has_the_contract_shape` reads the contract file itself.
|
||||
//!
|
||||
//! Read-only; one observer call per 2 s whatever the window asks; one node call per 5 s at most.
|
||||
use crate::engine::Shared;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(2);
|
||||
|
||||
struct NodeCache {
|
||||
at: Option<Instant>,
|
||||
blocks: Vec<RecentBlock>,
|
||||
/// the node said "method not found" (-32601): rest until then
|
||||
retry: Option<Instant>,
|
||||
}
|
||||
static NODE: Mutex<NodeCache> = Mutex::new(NodeCache { at: None, blocks: Vec::new(), retry: None });
|
||||
const NODE_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The observer's reply with this machine's blocks counted. `ids` are the 8-character vote key ids of this machine's
|
||||
/// cards; `state.you_blocks` counts the blocks whose `miner` is one of them and `state.source` names where the data came
|
||||
/// from. Every row passes through untouched (until 0.3.20 the field was rewritten to "you", which the feed contract refuses:
|
||||
/// the UI's `mine` function marks the lane from the card ids instead), so the contract holds on both surfaces.
|
||||
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
|
||||
let mut yours = 0u64;
|
||||
if let Some(blocks) = reply.get("blocks").and_then(|b| b.as_array()) {
|
||||
yours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false)).count() as u64;
|
||||
}
|
||||
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("you_blocks".into(), json!(yours));
|
||||
st.insert("source".into(), json!("observer"));
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
/// The observer URL for a window: `<live_api>?window=<s>`, the window clamped to the module's 30 to 300 s.
|
||||
pub fn url_for(live_api: &str, window_s: u32) -> String {
|
||||
format!("{live_api}?window={}", window_s.clamp(30, 300))
|
||||
}
|
||||
|
||||
/// One block from `igneum_getRecentBlocks`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecentBlock {
|
||||
pub hash: String,
|
||||
pub blue_score: u64,
|
||||
pub daa_score: u64,
|
||||
pub timestamp_ms: u64,
|
||||
pub vote_key_hash: String,
|
||||
pub is_chain_block: bool,
|
||||
pub color: String,
|
||||
/// "header" or "chain_block" (eec34ac3: a merged block whose own header was not at hand carries its merging
|
||||
/// chain block's time and blue score, marked so blocks_per_minute stays honest); "" on an older row = header
|
||||
pub timestamp_source: String,
|
||||
}
|
||||
|
||||
pub fn parse_recent(v: &Value) -> Vec<RecentBlock> {
|
||||
let s = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let n = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|b| RecentBlock {
|
||||
hash: s(b, "hash"),
|
||||
blue_score: n(b, "blue_score"),
|
||||
daa_score: n(b, "daa_score"),
|
||||
timestamp_ms: n(b, "timestamp_ms"),
|
||||
vote_key_hash: s(b, "vote_key_hash"),
|
||||
is_chain_block: b.get("is_chain_block").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||
color: s(b, "color"),
|
||||
timestamp_source: s(b, "timestamp_source"),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The lane id the UI matches against a card's `ids`: the first 8 hex of the vote key hash (engine.rs gives a card
|
||||
/// its ids the same way, `h.chars().take(8)`); "" when the block carries no key.
|
||||
pub fn lane(vote_key_hash: &str) -> String {
|
||||
vote_key_hash.trim_start_matches("0x").chars().take(8).collect()
|
||||
}
|
||||
|
||||
/// The site's `short`: the first 16 hex (site/api/live.mjs), so a hash reads the same from either source.
|
||||
fn short(h: &str) -> String {
|
||||
h.trim_start_matches("0x").chars().take(16).collect()
|
||||
}
|
||||
|
||||
/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10
|
||||
/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`, in the
|
||||
/// contract's row shape as far as the node knows it: no parents, no number, no proof state) and `miners` arrays
|
||||
/// (the contract's `{id, blocks, share, last_seen, engine}`). `now_ms` is the reference time.
|
||||
pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value {
|
||||
let ten = now_ms.saturating_sub(600_000);
|
||||
let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect();
|
||||
let mut miners: std::collections::BTreeMap<&str, u64> = Default::default();
|
||||
for b in &recent {
|
||||
if !b.vote_key_hash.is_empty() {
|
||||
*miners.entry(b.vote_key_hash.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut per_min = vec![0u64; 10];
|
||||
for b in &recent {
|
||||
let idx = ((b.timestamp_ms - ten) / 60_000).min(9) as usize;
|
||||
per_min[idx] += 1;
|
||||
}
|
||||
let win = now_ms.saturating_sub(window_s * 1000);
|
||||
let rows: Vec<Value> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.timestamp_ms >= win)
|
||||
.map(|b| {
|
||||
let id = lane(&b.vote_key_hash);
|
||||
json!({
|
||||
"hash": short(&b.hash), "number": Value::Null, "blue_score": b.blue_score, "daa": b.daa_score,
|
||||
"ts": b.timestamp_ms, "rx": b.timestamp_ms, "parents": [], "chain": b.is_chain_block,
|
||||
"miner": if id.is_empty() { Value::Null } else { json!(id) },
|
||||
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
|
||||
"locked": false, "final": false, "shards": [], "proven": false
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"miners_10m": miners.len(),
|
||||
"blocks_10m": recent.len(),
|
||||
"blocks_per_minute": per_min,
|
||||
"blocks": rows,
|
||||
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "blocks": n, "share": if recent.is_empty() { 0.0 } else { (*n as f64 * 1000.0 / recent.len() as f64).round() / 10.0 }, "last_seen": Value::Null, "engine": Value::Null })).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The node-only reply, in the contract's shape: every key of scene/feed-contract.json present, null where the node cannot
|
||||
/// know (the observer's lag, the mempool, the proving layer), `partial: true` and `state.source` = "node" as the documented
|
||||
/// extensions, `state.node` carrying the node's own numbers as on an observer reply. `now_iso` is the clock as ISO 8601.
|
||||
pub fn node_only_reply(blocks: &[RecentBlock], now_ms: u64, now_iso: &str, window_s: u64, node: Value, ids: &HashSet<String>) -> Value {
|
||||
let mut v = shape_from_blocks(blocks, now_ms, window_s);
|
||||
let o = v.as_object_mut().unwrap();
|
||||
let rows = o.remove("blocks").unwrap_or(Value::Null);
|
||||
let miners = o.remove("miners").unwrap_or(Value::Null);
|
||||
let state = json!({
|
||||
"stale": false, "age_s": 0, "network": Value::Null, "node_version": Value::Null, "height": Value::Null,
|
||||
"block_count": node.get("block_count").cloned().unwrap_or(Value::Null), "header_count": node.get("header_count").cloned().unwrap_or(Value::Null),
|
||||
"blue_score": node.get("blue_score").cloned().unwrap_or(Value::Null), "difficulty": node.get("difficulty").cloned().unwrap_or(Value::Null),
|
||||
"hashes_per_second_estimate": Value::Null, "peers": node.get("peers").cloned().unwrap_or(Value::Null), "mempool": Value::Null,
|
||||
"blocks_60s": Value::Null, "blocks_per_second_60s": Value::Null, "blocks_per_minute": o.get("blocks_per_minute").cloned().unwrap_or(json!([])),
|
||||
"miners_10m": o.get("miners_10m").cloned().unwrap_or(json!(0)), "observer_started_at": Value::Null, "observer_lag_s": Value::Null,
|
||||
"queue_depth": Value::Null, "updated_at": now_iso, "source": "node", "node": node
|
||||
});
|
||||
let finality = json!({ "supported": false, "active": false, "next_index": Value::Null, "latest_locked_index": Value::Null, "latest_locked_hash": Value::Null,
|
||||
"latest_locked_blue_score": Value::Null, "params": Value::Null, "weights": Value::Null, "checkpoints": [] });
|
||||
let out = json!({ "ok": true, "partial": true, "now": now_iso, "state": state, "blocks": rows, "miners": miners, "events": [], "finality": finality,
|
||||
"proving": { "supported": false, "reason": "the local node's rows: no proving layer in this view" } });
|
||||
let mut out = shape(out, ids);
|
||||
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("source".into(), json!("node")); // shape() names the observer; these rows are the node's
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The clock as the contract's `now`: ISO 8601 with milliseconds, UTC.
|
||||
fn iso_now(unix_s: f64) -> String {
|
||||
let ms = (unix_s * 1000.0) as i64;
|
||||
let (secs, millis) = (ms.div_euclid(1000), ms.rem_euclid(1000));
|
||||
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm), no chrono dependency
|
||||
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{millis:03}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
|
||||
}
|
||||
|
||||
/// The node's recent blocks, cached 5 s; empty when the node is down, carries no such method (-32601, rests 10
|
||||
/// minutes) or answered nothing.
|
||||
fn node_blocks(shared: &Arc<Shared>) -> Vec<RecentBlock> {
|
||||
{
|
||||
let c = NODE.lock().unwrap();
|
||||
if c.at.map(|t| t.elapsed() < NODE_TTL).unwrap_or(false) {
|
||||
return c.blocks.clone();
|
||||
}
|
||||
if c.retry.map(|t| Instant::now() < t).unwrap_or(false) {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced");
|
||||
if !node_up {
|
||||
return Vec::new();
|
||||
}
|
||||
match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(6)) {
|
||||
Ok(v) if v.is_array() => {
|
||||
let blocks = parse_recent(&v);
|
||||
let mut c = NODE.lock().unwrap();
|
||||
c.at = Some(Instant::now());
|
||||
c.blocks = blocks.clone();
|
||||
blocks
|
||||
}
|
||||
Ok(_) => Vec::new(),
|
||||
Err(e) => {
|
||||
// the node's default arm: {"code": -32601, "message": "method igneum_getRecentBlocks not found"}
|
||||
if e.contains("not found") || e.contains("-32601") {
|
||||
NODE.lock().unwrap().retry = Some(Instant::now() + Duration::from_secs(600));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The node's `state` fields for the Cards tab (`state.node` on an observer reply, `state` on a node-only one).
|
||||
fn node_state(shared: &Arc<Shared>, blocks: &[RecentBlock], now_ms: u64) -> Value {
|
||||
let mut v = shape_from_blocks(blocks, now_ms, 90);
|
||||
let st = shared.state.lock().unwrap();
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("blocks");
|
||||
o.remove("miners");
|
||||
o.insert("block_count".into(), json!(st.node.blocks));
|
||||
o.insert("header_count".into(), json!(st.node.headers));
|
||||
o.insert("daa".into(), json!(st.node.daa));
|
||||
o.insert("blue_score".into(), json!(st.node.blue));
|
||||
o.insert("difficulty".into(), json!(st.node.difficulty));
|
||||
o.insert("peers".into(), json!(st.node.peers));
|
||||
o.insert("synced".into(), json!(st.node.synced));
|
||||
o.insert("hashes_per_second_estimate".into(), Value::Null);
|
||||
o.insert("source".into(), json!("node"));
|
||||
v
|
||||
}
|
||||
|
||||
/// The reply for the dashboard: the observer's, cached 2 s per window, with the node's state fields added as
|
||||
/// `state.node` when the node answers; the node's rows alone (`partial: true`) when the observer is unreachable;
|
||||
/// `{ok:false, error}` when neither answers (the UI then draws its own blocks strip).
|
||||
pub fn fetch(shared: &Arc<Shared>, live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
|
||||
let window_s = window_s.clamp(30, 300);
|
||||
let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64;
|
||||
let blocks = node_blocks(shared);
|
||||
let node = if blocks.is_empty() { None } else { Some(node_state(shared, &blocks, now_ms)) };
|
||||
let cached = CACHE.lock().unwrap().as_ref().and_then(|(at, w, v)| if *w == window_s && at.elapsed() < TTL { Some(v.clone()) } else { None });
|
||||
let mut reply = match cached {
|
||||
Some(v) => v,
|
||||
None if live_api.is_empty() => json!({ "ok": false, "error": "no observer address in this build" }),
|
||||
None => {
|
||||
let url = url_for(live_api, window_s);
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), None, Duration::from_secs(6));
|
||||
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
|
||||
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
|
||||
_ => json!({ "ok": false, "error": "the observer did not answer" }),
|
||||
};
|
||||
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
|
||||
}
|
||||
reply
|
||||
}
|
||||
};
|
||||
let observer_ok = reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false);
|
||||
match (observer_ok, node) {
|
||||
(true, Some(n)) => {
|
||||
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("node".into(), n);
|
||||
}
|
||||
reply
|
||||
}
|
||||
(true, None) => reply,
|
||||
(false, Some(n)) => {
|
||||
// the node's rows stand in, in the contract's shape: blocks without parents or proof state, and the reply says so
|
||||
node_only_reply(&blocks, now_ms, &iso_now(crate::platform::unix_now_f()), window_s as u64, n, ids)
|
||||
}
|
||||
(false, None) => reply,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// a fixture in the observer's shape (site/api/live.mjs): three miners, this machine is 8fafda27, one of its blocks on
|
||||
// the selected chain and proven, one excluded; a locked and a pending checkpoint
|
||||
fn fixture() -> Value {
|
||||
json!({
|
||||
"ok": true, "now": 1791301670,
|
||||
"state": { "stale": false, "age_s": 1, "height": 198490 },
|
||||
"blocks": [
|
||||
{ "hash": "a1", "ts": 1791301600000i64, "blue_score": 194690, "daa": 198486, "parents": [], "chain": true, "color": "blue", "miner": "8fafda27", "locked": true, "final": true, "shards": [{ "state": "paid" }], "proven": true },
|
||||
{ "hash": "a2", "ts": 1791301601000i64, "blue_score": 194691, "daa": 198487, "parents": ["a1"], "chain": true, "color": "blue", "miner": "1b2c3d4e", "locked": false, "final": false, "shards": [{ "state": "proving" }], "proven": false },
|
||||
{ "hash": "a3", "ts": 1791301601500i64, "blue_score": null, "daa": 198487, "parents": ["a1"], "chain": false, "color": "red", "miner": "8fafda27", "locked": false, "final": false, "shards": [], "proven": false },
|
||||
{ "hash": "a4", "ts": 1791301602000i64, "blue_score": 194692, "daa": 198488, "parents": ["a2", "a3"], "chain": true, "color": "pending", "miner": "deadbeef", "locked": false, "final": false, "shards": [], "proven": false }
|
||||
],
|
||||
"finality": { "checkpoints": [
|
||||
{ "index": 6490, "blue_score": 194690, "daa": 198486, "state": "locked", "fraction_total": 0.71 },
|
||||
{ "index": 6491, "blue_score": 194720, "daa": 198516, "state": "pending", "fraction_total": 0.44 }
|
||||
] }
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_machines_blocks_are_counted_and_every_row_passes_through_untouched() {
|
||||
let ids: HashSet<String> = ["8fafda27".to_string(), "9a8b7c6d".to_string()].into_iter().collect();
|
||||
let out = shape(fixture(), &ids);
|
||||
let blocks = out["blocks"].as_array().unwrap();
|
||||
assert_eq!(blocks.len(), 4);
|
||||
assert_eq!(blocks[0]["miner"], "8fafda27", "the key id stays: the contract refuses a rewritten miner");
|
||||
assert_eq!(blocks[2]["miner"], "8fafda27");
|
||||
assert_eq!(blocks[1]["miner"], "1b2c3d4e");
|
||||
assert_eq!(blocks[3]["miner"], "deadbeef");
|
||||
// the rest of the contract passes through: parents, chain, colour, proof state, checkpoints
|
||||
assert_eq!(blocks[3]["parents"], json!(["a2", "a3"]));
|
||||
assert_eq!(blocks[0]["chain"], true);
|
||||
assert_eq!(blocks[2]["color"], "red");
|
||||
assert_eq!(blocks[0]["proven"], true);
|
||||
assert_eq!(blocks[1]["shards"][0]["state"], "proving");
|
||||
assert_eq!(out["finality"]["checkpoints"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(out["finality"]["checkpoints"][0]["state"], "locked");
|
||||
assert_eq!(out["state"]["you_blocks"], 2);
|
||||
assert_eq!(out["state"]["source"], "observer");
|
||||
assert_eq!(out["state"]["height"], 198490);
|
||||
assert_eq!(out["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ids_means_no_lane_is_yours_and_a_bare_reply_survives() {
|
||||
let out = shape(fixture(), &HashSet::new());
|
||||
assert_eq!(out["state"]["you_blocks"], 0);
|
||||
let bare = shape(json!({ "ok": true, "blocks": [] }), &HashSet::new());
|
||||
assert_eq!(bare["blocks"].as_array().unwrap().len(), 0);
|
||||
assert!(bare.get("state").is_none(), "no state object is invented");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_is_clamped_to_the_modules_range() {
|
||||
assert_eq!(url_for("https://igneum.network/api/live", 120), "https://igneum.network/api/live?window=120");
|
||||
assert_eq!(url_for("https://igneum.network/api/live", 5), "https://igneum.network/api/live?window=30");
|
||||
assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300");
|
||||
}
|
||||
|
||||
fn b(ts: u64, key: &str, chain: bool) -> RecentBlock {
|
||||
RecentBlock { hash: format!("0x{:064x}", ts), blue_score: ts / 1000, daa_score: ts / 1000, timestamp_ms: ts, vote_key_hash: key.into(), is_chain_block: chain, color: if chain { "blue".into() } else { String::new() }, timestamp_source: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_fields_come_from_the_nodes_blocks_of_the_last_ten_minutes() {
|
||||
let now = 1_791_300_000_000u64;
|
||||
let blocks = vec![b(now - 5_000, "aa", true), b(now - 30_000, "bb", true), b(now - 95_000, "aa", false), b(now - 500_000, "cc", true), b(now - 700_000, "dd", true)];
|
||||
let v = shape_from_blocks(&blocks, now, 120);
|
||||
assert_eq!(v["miners_10m"], 3, "dd is older than 10 minutes");
|
||||
assert_eq!(v["blocks_10m"], 4);
|
||||
let pm = v["blocks_per_minute"].as_array().unwrap();
|
||||
assert_eq!(pm.len(), 10);
|
||||
assert_eq!(pm[9], 2, "the newest minute holds the 5 s and 30 s blocks");
|
||||
assert_eq!(pm[8], 1, "the 95 s block");
|
||||
assert_eq!(pm[1], 1, "the 500 s block");
|
||||
assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window");
|
||||
assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour");
|
||||
assert_eq!(v["blocks"][0]["color"], "blue");
|
||||
assert_eq!(v["blocks"][0]["miner"], "aa", "the lane id is the first 8 hex of the vote key hash, as a card's ids");
|
||||
assert_eq!(v["blocks"][0]["chain"], true, "the scene's row shape");
|
||||
assert_eq!(v["blocks"][0]["parents"], json!([]), "the node method carries no parents");
|
||||
assert_eq!(v["miners"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(v["miners"][0]["id"], "aa", "the miners rows carry id, as the site's");
|
||||
assert_eq!(v["miners"][0]["blocks"], 2, "the contract's miner row: blocks, not blocks_10m");
|
||||
// this machine's rows are counted, never rewritten
|
||||
let ids: HashSet<String> = ["aa".to_string()].into_iter().collect();
|
||||
let marked = shape(json!({ "ok": true, "state": {}, "blocks": v["blocks"] }), &ids);
|
||||
assert_eq!(marked["blocks"][0]["miner"], "aa");
|
||||
assert_eq!(marked["state"]["you_blocks"], 2);
|
||||
assert_eq!(lane("0x0123456789abcdef"), "01234567");
|
||||
assert_eq!(lane(""), "");
|
||||
}
|
||||
|
||||
/// scene/feed-contract.json, the same file tools/scene/feed-contract.mjs reads: the node-only reply carries exactly the
|
||||
/// contract's keys (plus the documented extensions) at every level the scene reads.
|
||||
const CONTRACT: &str = include_str!("../../../scene/feed-contract.json");
|
||||
|
||||
fn keys_of(v: &Value) -> Vec<String> {
|
||||
let mut k: Vec<String> = v.as_object().expect("an object").keys().cloned().collect();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
fn listed(c: &Value, name: &str) -> Vec<String> {
|
||||
let mut k: Vec<String> = c[name].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_node_only_reply_has_the_contract_shape() {
|
||||
let c: Value = serde_json::from_str(CONTRACT).expect("scene/feed-contract.json parses");
|
||||
let now = 1_791_300_000_000u64;
|
||||
let blocks = vec![b(now - 5_000, "aa11bb22", true), b(now - 30_000, "bb22cc33", true), b(now - 95_000, "aa11bb22", false), b(now - 40_000, "", true)];
|
||||
let node = json!({ "block_count": 10, "header_count": 12, "blue_score": 9, "difficulty": 1.5, "peers": 3, "synced": true, "source": "node", "miners_10m": 2, "blocks_10m": 3, "blocks_per_minute": [0,0,0,0,0,0,0,0,1,2] });
|
||||
let ids: HashSet<String> = ["aa11bb22".to_string()].into_iter().collect();
|
||||
let out = node_only_reply(&blocks, now, "2026-10-07T13:20:00.000Z", 120, node, &ids);
|
||||
// top level: the contract's keys plus the documented extensions, nothing else
|
||||
let mut top = listed(&c, "top"); top.extend(listed(&c, "top_extensions")); top.sort();
|
||||
assert_eq!(keys_of(&out), top);
|
||||
assert_eq!(out["partial"], true);
|
||||
assert_eq!(out["now"], "2026-10-07T13:20:00.000Z", "now is the ISO string, not a float of seconds");
|
||||
// state: every contract key present, extras only from the extension list
|
||||
let state = listed(&c, "state");
|
||||
let ext = listed(&c, "state_extensions");
|
||||
for k in &state { assert!(out["state"].get(k).is_some(), "state lacks {k}"); }
|
||||
for k in keys_of(&out["state"]) { assert!(state.contains(&k) || ext.contains(&k), "state carries an unknown key {k}"); }
|
||||
assert_eq!(out["state"]["source"], "node");
|
||||
assert_eq!(out["state"]["you_blocks"], 2);
|
||||
assert_eq!(out["state"]["node"]["peers"], 3);
|
||||
// rows: exactly the contract's keys
|
||||
for row in out["blocks"].as_array().unwrap() { assert_eq!(keys_of(row), listed(&c, "block")); }
|
||||
assert_eq!(out["blocks"][0]["miner"], "aa11bb22");
|
||||
assert_eq!(out["blocks"][0]["number"], Value::Null);
|
||||
assert_eq!(out["blocks"][3]["miner"], Value::Null, "a block without a key reads null, never an empty string");
|
||||
assert_eq!(out["miners"][0]["id"], "aa11bb22");
|
||||
assert_eq!(out["miners"][0]["share"], 50.0, "two of the four blocks in ten minutes");
|
||||
for m in out["miners"].as_array().unwrap() { assert_eq!(keys_of(m), listed(&c, "miner")); }
|
||||
assert_eq!(keys_of(&out["finality"]), listed(&c, "finality"));
|
||||
assert_eq!(keys_of(&out["proving"]), listed(&c, "proving_unsupported"));
|
||||
assert_eq!(out["events"], json!([]));
|
||||
// the miner id is 8 hex or null, never a word
|
||||
let re_ok = |s: &str| s.len() == 8 && s.chars().all(|ch| ch.is_ascii_hexdigit());
|
||||
for row in out["blocks"].as_array().unwrap() { if let Some(m) = row["miner"].as_str() { assert!(re_ok(m), "miner {m}"); } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_now_formats_the_clock_as_the_contracts_now() {
|
||||
assert_eq!(iso_now(1_791_301_670.312), "2026-10-06T15:47:50.312Z");
|
||||
assert_eq!(iso_now(0.0), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(iso_now(951_782_400.5), "2000-02-29T00:00:00.500Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() {
|
||||
let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"},{"hash":"0xcd","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"ef","is_chain_block":false,"color":"red","timestamp_source":"chain_block"}]"#).unwrap();
|
||||
let p = parse_recent(&v);
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue"));
|
||||
assert_eq!((p[1].color.as_str(), p[1].timestamp_source.as_str()), ("red", "chain_block"));
|
||||
// a null vote_key_hash (a chain block with no mergeset entry, which the executor never produces) reads as ""
|
||||
let n: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":null,"is_chain_block":true,"color":"blue","timestamp_source":"header"}]"#).unwrap();
|
||||
assert_eq!(parse_recent(&n)[0].vote_key_hash, "");
|
||||
assert!(parse_recent(&json!(null)).is_empty());
|
||||
assert_eq!(short("0x1234567890abcdef00"), "1234567890abcdef", "the site's 16-hex short");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,19 +24,35 @@ mod server;
|
|||
mod state;
|
||||
mod manifest;
|
||||
mod ota;
|
||||
mod uiota;
|
||||
mod update;
|
||||
mod execrpc;
|
||||
mod jobs;
|
||||
mod jobrun;
|
||||
mod jobbuild;
|
||||
mod prover;
|
||||
mod provedefault;
|
||||
mod provingdir;
|
||||
mod segments;
|
||||
mod verifier;
|
||||
mod wslhost;
|
||||
mod sweep;
|
||||
mod ember;
|
||||
mod heat;
|
||||
mod powertask;
|
||||
mod watchdog;
|
||||
mod live;
|
||||
mod extnode;
|
||||
mod merge;
|
||||
mod ladder;
|
||||
mod chainfacts;
|
||||
mod card;
|
||||
mod drivertable;
|
||||
mod drivers;
|
||||
mod driverinstall;
|
||||
mod bootcheck;
|
||||
mod boot;
|
||||
mod rights;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::mpsc::channel;
|
||||
|
|
@ -44,7 +60,7 @@ 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 wrapper = args.iter().any(|a| a == "--wrapper") && !args.iter().any(|a| a == "--boot");
|
||||
let sweep = args.iter().any(|a| a == "--sweep");
|
||||
if sweep {
|
||||
// the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the sweep
|
||||
|
|
@ -55,28 +71,66 @@ fn main() {
|
|||
println!("igneum-app {}", engine::VERSION);
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|a| a == "--rights") {
|
||||
// the installer's one elevated step (src/rights.rs): compares the installed rights manifest with this build's list,
|
||||
// asks for administrator rights once when something is missing, else exits at once; exit 0 either way (an install
|
||||
// never fails on a declined prompt: the app runs, the missing right is a notice)
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let install_dir = exe.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let runtime = config::Runtime::from_env();
|
||||
match rights::install(&exe, &install_dir, &crate::platform::fixed_data_root(), &runtime.app_dir, engine::VERSION) {
|
||||
Ok(true) => println!("rights: set up (one administrator approval)"),
|
||||
Ok(false) => println!("rights: nothing new to set up"),
|
||||
Err(e) if e.starts_with("rights: deferred") => println!("{e}"),
|
||||
Err(e) => println!("rights: not set up ({e}); the app runs, and the missing rights are notices in Settings"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|a| a == "--power-helper") {
|
||||
// the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands
|
||||
// from <app data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes
|
||||
let dir = powertask::helper_dir();
|
||||
std::process::exit(powertask::run_helper(&dir));
|
||||
}
|
||||
// 0.3.22: `--data-root <path>` pins the data root (the boot task spells it out: an S4U session may not load the
|
||||
// profile); `--boot` is the headless engine (no window host, no browser); `--launch` with no interactive session is
|
||||
// `--boot` (src/boot.rs launch_mode: PC 2 waited 67 minutes for a logon on 7 October 2026)
|
||||
if let Some(i) = args.iter().position(|a| a == "--data-root") {
|
||||
if let Some(p) = args.get(i + 1) {
|
||||
std::env::set_var("IGNEUM_APP_DATA", p);
|
||||
}
|
||||
}
|
||||
let boot = args.iter().any(|a| a == "--boot");
|
||||
let mut no_open = no_open || boot;
|
||||
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("Igneum Miner.exe");
|
||||
if host.exists() {
|
||||
let mut c = std::process::Command::new(&host);
|
||||
c.current_dir(&dir);
|
||||
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
|
||||
if c.spawn().is_ok() {
|
||||
return;
|
||||
let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
let host = dir.as_ref().map(|d| d.join("Igneum Miner.exe"));
|
||||
let host_exists = host.as_ref().map(|h| h.exists()).unwrap_or(false);
|
||||
match boot::launch_mode(std::env::var("SESSIONNAME").ok().as_deref(), host_exists) {
|
||||
boot::LaunchMode::Host => {
|
||||
if let (Some(dir), Some(host)) = (dir.as_ref(), host.as_ref()) {
|
||||
let mut c = std::process::Command::new(host);
|
||||
c.current_dir(dir);
|
||||
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
|
||||
if c.spawn().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
boot::LaunchMode::Headless => no_open = true,
|
||||
boot::LaunchMode::Browser => {}
|
||||
}
|
||||
// no window host: the engine runs on its own and the dashboard opens in the default browser
|
||||
// no window host (or no logon): the engine runs on its own; the dashboard opens in the default browser only in a session
|
||||
}
|
||||
platform::clear_quarantine();
|
||||
let runtime = config::Runtime::from_env();
|
||||
// 0.3.22: a window host's engine that finds the boot engine already running under this data root becomes the
|
||||
// bridge to it (src/boot.rs) instead of a second engine on the same ports and folder
|
||||
if wrapper && !sweep {
|
||||
if let Some(url) = boot::running_engine(&runtime.app_dir) {
|
||||
std::process::exit(boot::run_bridge(&url));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::create_dir_all(&runtime.app_dir);
|
||||
let _ = std::fs::create_dir_all(&runtime.log_dir);
|
||||
platform::lock_permissions(&runtime.app_dir, true);
|
||||
|
|
@ -101,6 +155,17 @@ fn main() {
|
|||
}
|
||||
}
|
||||
let packaged = config::Packaged::load(&candidates).with_env_overrides();
|
||||
// Devnet 3 (7 October 2026): the package names the network its node joins; the runtime read above knew only the
|
||||
// environment, so it is read again with the package's suffix and peers (the environment still wins inside)
|
||||
// the network step (0.3.23): the saved choice is read first (the settings file lives in <data root>/app whatever the
|
||||
// network), so settings.network can turn the runtime to the testnet object before the node starts
|
||||
let chosen_network = config::Settings::load(&runtime.app_dir.join("settings.json")).network;
|
||||
let runtime = if packaged.node_devnet_suffix.is_some() || packaged.node_peers.is_some() || !chosen_network.is_empty() {
|
||||
config::Runtime::from_env_with_choice(packaged.node_devnet_suffix, packaged.node_peers.as_deref(), &chosen_network)
|
||||
} else {
|
||||
runtime
|
||||
};
|
||||
let _ = std::fs::create_dir_all(&runtime.app_dir);
|
||||
let settings = config::Settings::load(&runtime.app_dir.join("settings.json"));
|
||||
// a measurement engine runs with the installed app's choices and its own switches (config.rs for_measurement)
|
||||
let settings = if sweep { settings.for_measurement() } else { settings };
|
||||
|
|
@ -155,7 +220,8 @@ fn main() {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
if wrapper {
|
||||
// 0.3.22: only an engine a host attached quits with the host (src/boot.rs stdin_close_quits)
|
||||
if boot::stdin_close_quits(wrapper, boot) {
|
||||
shared.send(engine::Cmd::Quit("the window host went away (stdin closed)"));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@
|
|||
//! "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": "", "override": {...} },
|
||||
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
|
||||
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } },
|
||||
//! "ui": { "version": "0.3.19.1", "sha256": "<64 hex>", "size": 412345, "url": "https://dl.../ui/igneum-ui-0.3.19.1.tar.gz",
|
||||
//! "min_engine": "0.3.19", "signature": "<128 hex>" }
|
||||
//! }
|
||||
//! `ui` (7 October 2026, docs/plans/ui-ota.md) is the interface channel: a tar.gz of app/igneum-app/ui the engine serves
|
||||
//! in place of its embedded copy once the hash and the entry's own Ed25519 signature (over `ui_sign_bytes`, the same
|
||||
//! release key) check; the whole manifest's signature covers it too. Removing the object is the kill switch.
|
||||
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
|
||||
//! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it
|
||||
//! to <app data>/tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE).
|
||||
|
|
@ -52,6 +57,9 @@ pub struct Manifest {
|
|||
pub min_supported_version: String,
|
||||
pub notes: String,
|
||||
pub activation_height: Option<u64>,
|
||||
/// Horizon frontier lane (6 October 2026): the publisher's word that this version installs even while the
|
||||
/// network's finality is paused (nothing else does); false unless the signed manifest says so
|
||||
pub urgent: bool,
|
||||
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.
|
||||
|
|
@ -59,6 +67,41 @@ pub struct Manifest {
|
|||
/// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries
|
||||
/// it), written as is to <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
|
||||
pub tuning: Option<serde_json::Value>,
|
||||
/// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published
|
||||
pub ui: Option<UiEntry>,
|
||||
/// the network step (0.3.23): the network a FRESH install selects by default ("devnet-3" | "testnet-1"; "" = the
|
||||
/// package's own), and whether igneum-testnet-1 is open; a manifest naming the testnet default before the open is refused
|
||||
pub default_network: String,
|
||||
pub testnet_open: bool,
|
||||
/// drivers: the per-vendor driver table (src/drivers.rs; branch driver-check, 7 October 2026), validated here and
|
||||
/// written as is to <app data>/drivers.json; None = no table published, the rows say nothing about drivers
|
||||
pub drivers: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// One published interface bundle (the manifest's `ui` object).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct UiEntry {
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub url: String,
|
||||
/// the lowest engine version that may serve this bundle; a newer bundle for an older engine is ignored
|
||||
pub min_engine: String,
|
||||
/// Ed25519 over `ui_sign_bytes(version, sha256, min_engine)` by the release key, 128 hex
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// The bytes the interface entry's own signature covers: a fixed tag, then the version, the hash and the engine
|
||||
/// floor, one per line. The url and the size are not covered: the hash is what the engine trusts after the download.
|
||||
pub fn ui_sign_bytes(version: &str, sha256: &str, min_engine: &str) -> Vec<u8> {
|
||||
format!("igneum-ui\n{version}\n{}\n{min_engine}\n", sha256.to_ascii_lowercase()).into_bytes()
|
||||
}
|
||||
|
||||
/// The entry's own signature against the release key (the whole manifest's signature is checked before this).
|
||||
pub fn verify_ui_entry(e: &UiEntry, pub_hex: &str) -> Result<(), String> {
|
||||
let key = public_key(pub_hex)?;
|
||||
let sig = hex_decode(&e.signature).and_then(|b| Signature::from_slice(&b).ok()).ok_or("interface signature is not 128 hex characters")?;
|
||||
key.verify(&ui_sign_bytes(&e.version, &e.sha256, &e.min_engine), &sig).map_err(|_| "interface signature does not verify".to_string())
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
|
|
@ -148,7 +191,17 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
Ok(Some(e))
|
||||
};
|
||||
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let default_network = s(&v, "default_network");
|
||||
let testnet_open = v.get("testnet_open").and_then(|b| b.as_bool()).unwrap_or(false);
|
||||
if !default_network.is_empty() && !["devnet-3", "testnet-1"].contains(&default_network.as_str()) {
|
||||
return Err(format!("default_network '{default_network}' is not a network this app knows"));
|
||||
}
|
||||
if default_network == "testnet-1" && !testnet_open {
|
||||
return Err("default_network names the testnet before it is open (testnet_open is not true)".into());
|
||||
}
|
||||
Ok(Manifest {
|
||||
default_network,
|
||||
testnet_open,
|
||||
version,
|
||||
published_at: s(&v, "published_at"),
|
||||
channel: s(&v, "channel"),
|
||||
|
|
@ -157,6 +210,8 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
min_supported_version: s(&v, "min_supported_version"),
|
||||
notes: s(&v, "notes"),
|
||||
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
|
||||
// the manifest's TOP-LEVEL "urgent": true (the publisher sets it; igneum-ota-sign passes the document through)
|
||||
urgent: v.get("urgent").and_then(|u| u.as_bool()).unwrap_or(false),
|
||||
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()),
|
||||
|
|
@ -168,6 +223,40 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()),
|
||||
_ => None,
|
||||
},
|
||||
drivers: match v.get("drivers") {
|
||||
Some(d) if d.is_object() => {
|
||||
crate::drivertable::Table::parse(d)?;
|
||||
Some(d.clone())
|
||||
}
|
||||
Some(d) if !d.is_null() => return Err("drivers must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
ui: match v.get("ui") {
|
||||
Some(u) if u.is_object() => {
|
||||
let e = UiEntry { version: s(u, "version"), sha256: s(u, "sha256").to_ascii_lowercase(), size: u.get("size").and_then(|x| x.as_u64()).unwrap_or(0), url: s(u, "url"), min_engine: s(u, "min_engine"), signature: s(u, "signature").to_ascii_lowercase() };
|
||||
if parse_version(&e.version).is_none() {
|
||||
return Err(format!("ui: version '{}' is not a version", e.version));
|
||||
}
|
||||
if parse_version(&e.min_engine).is_none() {
|
||||
return Err(format!("ui: min_engine '{}' is not a version", e.min_engine));
|
||||
}
|
||||
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
|
||||
return Err("ui: the url is not https".into());
|
||||
}
|
||||
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("ui: sha256 is not 64 hex characters".into());
|
||||
}
|
||||
if e.size == 0 {
|
||||
return Err("ui: size is missing".into());
|
||||
}
|
||||
if e.signature.len() != 128 || !e.signature.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("ui: signature is not 128 hex characters".into());
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
Some(u) if !u.is_null() => return Err("ui must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +389,10 @@ pub struct Moment {
|
|||
pub boundary_eta_s: Option<i64>,
|
||||
/// A worker is starting, exporting a pack or being built: let it finish.
|
||||
pub miner_busy: bool,
|
||||
/// A remote job is running on this engine (src/jobrun.rs). It holds even an urgent install: a job is bounded by its
|
||||
/// cap, and an install under it kills its process tree (PC 1, 6 October 2026, 17:52:54Z: 0.3.14 went in during a
|
||||
/// measurement job because install_asked from a two-hour-old update-now still counted as urgent).
|
||||
pub job_active: 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.
|
||||
|
|
@ -308,8 +401,16 @@ pub struct Moment {
|
|||
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,
|
||||
/// Horizon frontier lane (6 October 2026): the network's finality is paused (the node is synced and no checkpoint
|
||||
/// has locked for FINALITY_PAUSE_S); a rollout never lands on a chain that cannot lock
|
||||
pub finality_paused: bool,
|
||||
/// the signed manifest's own urgent flag: the one thing that installs while finality is paused
|
||||
pub manifest_urgent: bool,
|
||||
}
|
||||
|
||||
/// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes).
|
||||
pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0;
|
||||
|
||||
/// 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;
|
||||
|
||||
|
|
@ -320,6 +421,13 @@ pub fn slot_minute(id8: &str) -> u64 {
|
|||
|
||||
/// 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> {
|
||||
// the finality rule comes first: a fork-close or unsupported urgency does not pass it, only the manifest's flag
|
||||
if m.finality_paused && !m.manifest_urgent {
|
||||
return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into());
|
||||
}
|
||||
if m.job_active {
|
||||
return Err("a remote job is running; installing when it closes".into());
|
||||
}
|
||||
if m.urgent {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -349,7 +457,10 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
|||
/// 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,
|
||||
// Horizon polish Q4 (6 October 2026): an activation at or below the DAA has PASSED, nothing is pending; the
|
||||
// old rule read it as close, so every update since the 0.3.14 manifest said "0 blocks away, installing now",
|
||||
// stripped Later and skipped every safe-moment guard (PC 1's 17:52:54Z install under a job came through it)
|
||||
Some(h) if daa > 0 && h > daa => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -382,6 +493,36 @@ mod tests {
|
|||
assert_eq!(ours, theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ui_entry_parses_and_every_bad_field_fails() {
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
let sk = SigningKey::from_bytes(&[3u8; 32]);
|
||||
let pk = hex_encode(sk.verifying_key().as_bytes());
|
||||
let sha = "ab".repeat(32);
|
||||
let sig = hex_encode(&sk.sign(&ui_sign_bytes("1.0.1", &sha, "0.3.19")).to_bytes());
|
||||
let base = serde_json::json!({ "version": "0.3.19", "platforms": {}, "ui": { "version": "1.0.1", "sha256": sha, "size": 400000, "url": "https://dl.igneum.network/dl/t/ui/igneum-ui-1.0.1.tar.gz", "min_engine": "0.3.19", "signature": sig } });
|
||||
let m = parse(&base.to_string()).unwrap();
|
||||
let u = m.ui.clone().unwrap();
|
||||
assert_eq!(u.version, "1.0.1");
|
||||
assert_eq!(u.min_engine, "0.3.19");
|
||||
assert!(verify_ui_entry(&u, &pk).is_ok());
|
||||
let mut t = u.clone();
|
||||
t.sha256 = "00".repeat(32);
|
||||
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed hash breaks the entry's signature");
|
||||
let mut t = u.clone();
|
||||
t.min_engine = "0.3.0".into();
|
||||
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed engine floor breaks it too");
|
||||
assert!(verify_ui_entry(&u, &hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes())).is_err());
|
||||
assert_eq!(parse(r#"{"version":"0.3.19","platforms":{}}"#).unwrap().ui, None, "no ui object: the kill switch");
|
||||
for (k, v) in [("version", serde_json::json!("x")), ("min_engine", serde_json::json!("")), ("url", serde_json::json!("http://evil/x.tar.gz")), ("sha256", serde_json::json!("abc")), ("size", serde_json::json!(0)), ("signature", serde_json::json!("zz"))] {
|
||||
let mut b = base.clone();
|
||||
b["ui"][k] = v;
|
||||
assert!(parse(&b.to_string()).is_err(), "ui.{k} bad must fail");
|
||||
}
|
||||
assert!(parse(r#"{"version":"0.3.19","platforms":{},"ui":"x"}"#).is_err());
|
||||
assert_eq!(std::str::from_utf8(&ui_sign_bytes("1.0.1", "AB", "0.3.19")).unwrap(), "igneum-ui\n1.0.1\nab\n0.3.19\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuning_parses() {
|
||||
let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap();
|
||||
|
|
@ -408,6 +549,22 @@ mod tests {
|
|||
(sk, pk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_network_rules() {
|
||||
// the network step: absent = the package's own; devnet-3 fine; the testnet default only once the manifest opens it
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert_eq!(m.default_network, "");
|
||||
assert!(!m.testnet_open);
|
||||
let d = parse(r#"{"version":"0.3.23","default_network":"devnet-3"}"#).unwrap();
|
||||
assert_eq!(d.default_network, "devnet-3");
|
||||
let e = parse(r#"{"version":"0.3.23","default_network":"testnet-1"}"#).unwrap_err();
|
||||
assert!(e.contains("before it is open"), "{e}");
|
||||
let t = parse(r#"{"version":"0.3.23","default_network":"testnet-1","testnet_open":true}"#).unwrap();
|
||||
assert_eq!(t.default_network, "testnet-1");
|
||||
assert!(t.testnet_open);
|
||||
assert!(parse(r#"{"version":"0.3.23","default_network":"mainnet"}"#).unwrap_err().contains("not a network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_manifest() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
|
|
@ -492,8 +649,15 @@ mod tests {
|
|||
|
||||
#[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 };
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false };
|
||||
assert!(safe_to_apply(&base).is_ok());
|
||||
// the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install;
|
||||
// paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, ..base.clone() }).unwrap_err().starts_with("waiting for finality"));
|
||||
assert!(safe_to_apply(&Moment { finality_paused: false, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, manifest_urgent: true, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
|
||||
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());
|
||||
|
|
@ -502,6 +666,11 @@ mod tests {
|
|||
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());
|
||||
// PC 1, 6 October 2026, 17:52:54Z: a scheduled update arriving mid-job is deferred to the job's close, urgent or not
|
||||
assert!(safe_to_apply(&Moment { job_active: true, ..base.clone() }).unwrap_err().contains("remote job"));
|
||||
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, ..base.clone() }).unwrap_err().contains("remote job"));
|
||||
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, slot_ok: false, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { job_active: false, urgent: true, slot_ok: false, ..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());
|
||||
|
|
@ -531,8 +700,11 @@ mod tests {
|
|||
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));
|
||||
assert!(fork_is_close(Some(120_000), 119_999), "one block before the activation");
|
||||
// a passed activation is not close (Horizon polish Q4): at the height and after it, nothing is pending
|
||||
assert!(!fork_is_close(Some(120_000), 120_000));
|
||||
assert!(!fork_is_close(Some(120_000), 130_000));
|
||||
assert!(!fork_is_close(Some(33_000), 201_776), "the 0.3.14 manifest's case: difficulty v2 at 33,000 against PC 1's DAA");
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert!(!unsupported(&m, "0.3.0"));
|
||||
assert!(unsupported(&m, "0.2.9"));
|
||||
|
|
|
|||
96
app/igneum-app/src/merge.rs
Normal file
96
app/igneum-app/src/merge.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! Is this node's work merging into the network? (design note, 7 October 2026, from the node lane's 30-s
|
||||
//! propagation reading: a node behind a slow link keeps mining its own chain, its relayed blocks never merge, and the
|
||||
//! N4 "synced" test (tip moving, a peer present) cannot see it.)
|
||||
//!
|
||||
//! The decision is pure. The engine feeds it our sink's blue score (the watch line's `blue=`), the sinks the network
|
||||
//! reports (the observer's view, and any peer that answers igneum_getNodeInfo), the merge depth from the node's params,
|
||||
//! how long ago one of our blocks was last seen in the network's view, and how long we have been mining.
|
||||
|
||||
/// How long our blocks may stay out of the network's view before the node reads "behind" (two minutes).
|
||||
pub const NOT_MERGING_S: f64 = 120.0;
|
||||
/// The merge depth when the node does not say (node lane, 7 October 2026: 1 bps x MERGE_DEPTH_DURATION = 3,600 in blue
|
||||
/// score on the devnet and every 1-bps network; 36,000 at 10 bps; read from igneum_getNodeInfo's `blockrate.mergeDepth`
|
||||
/// once the node carries it). Peer sinks: nothing exposes them yet; `igneum_getPeers` with lastDeliveredBlueScore is on
|
||||
/// the 0.3.19 list, and the observer is the only network view until then.
|
||||
pub const DEFAULT_MERGE_DEPTH: u64 = 3_600;
|
||||
/// The words every surface uses for this state.
|
||||
pub const NOT_MERGING: &str = "behind: your blocks are not merging into the network";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MergeCheck {
|
||||
/// our node's sink blue score
|
||||
pub our_blue: u64,
|
||||
/// the sinks' blue scores the network reports (observer, peers); empty = no view, no decision
|
||||
pub peer_sinks: Vec<u64>,
|
||||
/// the merge depth in blue score (the node's params, else DEFAULT_MERGE_DEPTH)
|
||||
pub merge_depth: u64,
|
||||
/// seconds since one of our blocks last appeared in the network's view; None = never seen
|
||||
pub ours_seen_ago_s: Option<f64>,
|
||||
/// how long this machine has been mining with a synced node, in seconds
|
||||
pub mining_for_s: f64,
|
||||
}
|
||||
|
||||
/// True when every sink the network reports is more than the merge depth ahead of ours and none of our blocks has
|
||||
/// appeared in the network's view for NOT_MERGING_S (so the miner's blocks can no longer merge: hold it).
|
||||
pub fn not_merging(c: &MergeCheck) -> bool {
|
||||
if c.peer_sinks.is_empty() || c.mining_for_s < NOT_MERGING_S {
|
||||
return false;
|
||||
}
|
||||
let depth = if c.merge_depth == 0 { DEFAULT_MERGE_DEPTH } else { c.merge_depth };
|
||||
let all_ahead = c.peer_sinks.iter().all(|&s| s > c.our_blue.saturating_add(depth));
|
||||
let ours_absent = c.ours_seen_ago_s.map(|a| a >= NOT_MERGING_S).unwrap_or(true);
|
||||
all_ahead && ours_absent
|
||||
}
|
||||
|
||||
/// The network's view out of an observer reply (crate::live::fetch's shape): the highest blue score it shows, and the
|
||||
/// newest timestamp (unix ms) of a block it marks as ours (miner "you"). None when the reply carries no blocks.
|
||||
pub fn view_of(reply: &serde_json::Value) -> Option<(u64, Option<i64>)> {
|
||||
let blocks = reply.get("blocks")?.as_array()?;
|
||||
if blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sink = blocks.iter().filter_map(|b| b.get("blue_score").and_then(|v| v.as_u64())).max().unwrap_or(0);
|
||||
let ours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()) == Some("you")).filter_map(|b| b.get("ts").and_then(|v| v.as_i64())).max();
|
||||
Some((sink, ours))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn c() -> MergeCheck { MergeCheck { our_blue: 100_000, peer_sinks: vec![104_000, 103_800], merge_depth: 3_600, ours_seen_ago_s: None, mining_for_s: 600.0 } }
|
||||
|
||||
/// The slow-link node (node lane, 30-s propagation reading): tip moving, a peer present, its own chain, nothing
|
||||
/// merging. Today's sync test calls it synced; this one holds the miner.
|
||||
#[test]
|
||||
fn a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner() {
|
||||
assert!(not_merging(&c()), "every peer more than the merge depth ahead and ours never seen");
|
||||
assert!(not_merging(&MergeCheck { ours_seen_ago_s: Some(130.0), ..c() }), "ours last seen over two minutes ago");
|
||||
assert_eq!(NOT_MERGING, "behind: your blocks are not merging into the network");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merging_node_is_left_alone() {
|
||||
assert!(!not_merging(&MergeCheck { ours_seen_ago_s: Some(20.0), ..c() }), "our block appeared in the network's view");
|
||||
assert!(!not_merging(&MergeCheck { peer_sinks: vec![104_000, 101_000], ..c() }), "one peer within the merge depth");
|
||||
assert!(!not_merging(&MergeCheck { peer_sinks: vec![103_600], ..c() }), "exactly the merge depth is not over it");
|
||||
assert!(!not_merging(&MergeCheck { peer_sinks: vec![], ..c() }), "no network view, no decision");
|
||||
assert!(!not_merging(&MergeCheck { mining_for_s: 30.0, ..c() }), "not mining long enough to judge");
|
||||
assert!(not_merging(&MergeCheck { merge_depth: 0, peer_sinks: vec![103_601], ..c() }), "a missing depth falls back to 3,600");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_observer_reply_gives_the_sink_and_our_newest_block() {
|
||||
let r = json!({ "blocks": [
|
||||
{ "blue_score": 194_690, "ts": 1_791_301_600_000i64, "miner": "8fafda27" },
|
||||
{ "blue_score": 194_692, "ts": 1_791_301_602_000i64, "miner": "you" },
|
||||
{ "blue_score": null, "ts": 1_791_301_601_500i64, "miner": "you" },
|
||||
{ "blue_score": 194_691, "ts": 1_791_301_601_000i64, "miner": "deadbeef" } ] });
|
||||
assert_eq!(view_of(&r), Some((194_692, Some(1_791_301_602_000))));
|
||||
let none_ours = json!({ "blocks": [{ "blue_score": 5, "ts": 1, "miner": "x" }] });
|
||||
assert_eq!(view_of(&none_ours), Some((5, None)));
|
||||
assert_eq!(view_of(&json!({ "blocks": [] })), None);
|
||||
assert_eq!(view_of(&json!({ "ok": false })), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -68,8 +68,12 @@ pub enum Launch {
|
|||
|
||||
pub struct Ctx {
|
||||
pub node_synced: bool,
|
||||
/// the network's finality is paused (engine: synced and no lock for manifest::FINALITY_PAUSE_S)
|
||||
pub finality_paused: bool,
|
||||
pub boundary_eta_s: Option<i64>,
|
||||
pub miner_busy: bool,
|
||||
/// A remote job is running (src/jobrun.rs): the install holds, urgent or not.
|
||||
pub job_active: bool,
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +94,8 @@ pub struct Updater {
|
|||
dir: PathBuf,
|
||||
auto: bool,
|
||||
manifest: Option<Manifest>,
|
||||
/// driver-check: the table was written or removed since the engine last looked
|
||||
drivers_changed: bool,
|
||||
entry: Option<PlatformEntry>,
|
||||
file: Option<PathBuf>,
|
||||
staged: Option<PathBuf>,
|
||||
|
|
@ -129,6 +135,9 @@ pub struct Updater {
|
|||
live_next: Instant,
|
||||
live_busy: bool,
|
||||
live_api: String,
|
||||
/// ui-ota: the interface entry of the last verified manifest, handed to src/uiota.rs once per check
|
||||
/// (Some(None) = the channel was withdrawn: the kill switch)
|
||||
ui_change: Option<Option<manifest::UiEntry>>,
|
||||
}
|
||||
|
||||
impl Updater {
|
||||
|
|
@ -149,6 +158,7 @@ impl Updater {
|
|||
dir,
|
||||
auto,
|
||||
manifest: None,
|
||||
drivers_changed: false,
|
||||
entry: None,
|
||||
file: None,
|
||||
staged: None,
|
||||
|
|
@ -176,6 +186,7 @@ impl Updater {
|
|||
live_next: now,
|
||||
live_busy: false,
|
||||
live_api: env("IGNEUM_APP_LIVE_API").unwrap_or_else(|| live_api_from(&shared.packaged.live_page)),
|
||||
ui_change: None,
|
||||
};
|
||||
shared.log(&format!("update slot: minute {} of every hour (machine id {})", u.slot, shared.runtime.id8()));
|
||||
#[cfg(windows)]
|
||||
|
|
@ -188,6 +199,7 @@ impl Updater {
|
|||
// installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here)
|
||||
if !shared.runtime.sweep_only {
|
||||
firewall_first_run(shared);
|
||||
boot_task_first_run(shared);
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
|
@ -197,6 +209,7 @@ impl Updater {
|
|||
u.min_supported = m.min_supported_version.clone();
|
||||
u.write_override(shared, &m);
|
||||
u.write_tuning(shared, &m);
|
||||
u.write_drivers(shared, &m);
|
||||
}
|
||||
}
|
||||
u.settle_previous(shared);
|
||||
|
|
@ -270,7 +283,49 @@ impl Updater {
|
|||
}
|
||||
}
|
||||
|
||||
/// <app data>/drivers.json: the manifest's per-vendor driver table (src/drivers.rs), written as is; the engine
|
||||
/// re-reads it and re-evaluates every card's offer on a change. A manifest without a table removes the file.
|
||||
fn write_drivers(&mut self, shared: &Arc<Shared>, m: &Manifest) {
|
||||
let path = self.app_dir.join("drivers.json");
|
||||
match &m.drivers {
|
||||
Some(t) => {
|
||||
let text = t.to_string();
|
||||
if std::fs::read_to_string(&path).ok().as_deref() == Some(text.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, &text) {
|
||||
shared.log(&format!("could not write {}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
let n = t.get("vendors").and_then(|c| c.as_object()).map(|c| c.len()).unwrap_or(0);
|
||||
shared.event("info", &format!("driver table from the signed manifest: {n} vendor(s), updated {}", t.get("updated").and_then(|u| u.as_str()).unwrap_or("?")));
|
||||
self.drivers_changed = true;
|
||||
}
|
||||
None => {
|
||||
if path.is_file() && std::fs::remove_file(&path).is_ok() {
|
||||
shared.log("the manifest carries no driver table any more; drivers.json removed");
|
||||
self.drivers_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver table changed (written or removed), once per change.
|
||||
pub fn take_drivers_change(&mut self) -> bool {
|
||||
std::mem::take(&mut self.drivers_changed)
|
||||
}
|
||||
|
||||
pub fn drivers_table(&self) -> Option<crate::drivers::Table> {
|
||||
let text = std::fs::read_to_string(self.app_dir.join("drivers.json")).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
crate::drivers::Table::parse(&v).ok()
|
||||
}
|
||||
|
||||
/// The tuning file, once per change (the engine only logs it: the workers read the file at their next prepare).
|
||||
/// ui-ota: the interface entry of the last check, once (None until a check has run)
|
||||
pub fn take_ui_change(&mut self) -> Option<Option<manifest::UiEntry>> {
|
||||
self.ui_change.take()
|
||||
}
|
||||
pub fn take_tuning_change(&mut self) -> Option<PathBuf> {
|
||||
self.tuning_changed.take()
|
||||
}
|
||||
|
|
@ -326,6 +381,12 @@ impl Updater {
|
|||
let rolled_back = v.get("rolled_back").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
let deferred = v.get("deferred").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
let ver = v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
// 0.3.21: the helper says how the app came back (ok, rolled-back, relaunched, installer-failed) and after how long
|
||||
let ret = v.get("return").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let ready_s = v.get("ready_s").and_then(|x| x.as_i64()).unwrap_or(-1);
|
||||
if !ret.is_empty() {
|
||||
shared.log(&format!("update-return: the helper reports '{ret}' for {ver}{}", if ready_s >= 0 { format!(", an engine answered after {ready_s} s") } else { ", no engine answered inside its window".to_string() }));
|
||||
}
|
||||
if !ok && deferred {
|
||||
// the installer never ran (nobody answered the administrator prompt): not a failure, it tries again
|
||||
shared.log(&format!("OTA: the update to {ver} was deferred before this start ({err}); it tries again in this machine's slot"));
|
||||
|
|
@ -372,6 +433,12 @@ impl Updater {
|
|||
self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The version this engine replaced, on the first start after an update (MF-11: the read-back line the engine logs
|
||||
/// as its first act, "app <version> up after the update from <from>"); None on any other start.
|
||||
pub fn updated_from(&self) -> Option<String> {
|
||||
self.pending.as_ref().filter(|p| p.starts == 1 && p.to == self.current).map(|p| p.from.clone())
|
||||
}
|
||||
|
||||
// ---- state for the dashboard -------------------------------------------------------------------------------------
|
||||
|
||||
fn publish(&self, shared: &Arc<Shared>) {
|
||||
|
|
@ -539,7 +606,8 @@ impl Updater {
|
|||
}
|
||||
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
|
||||
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
|
||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
|
||||
let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false);
|
||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , finality_paused: ctx.finality_paused, manifest_urgent };
|
||||
if !self.auto && !urgent && !self.install_asked {
|
||||
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
|
||||
return None;
|
||||
|
|
@ -668,6 +736,12 @@ impl Updater {
|
|||
shared.log(&format!("update check: {} is published but has no {} build yet", m.version, manifest::platform_name()));
|
||||
} else {
|
||||
shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version));
|
||||
// an asked install (update-now) covers this one check: nothing newer, so the ask is spent.
|
||||
// Left true it made the NEXT manifest urgent hours later (PC 1, 6 October 2026, 17:52:54Z).
|
||||
if self.install_asked {
|
||||
self.install_asked = false;
|
||||
shared.log("update check: Install now asked and nothing newer is published; the ask is spent (the next manifest takes the usual slot)");
|
||||
}
|
||||
}
|
||||
self.entry = None;
|
||||
self.file = None;
|
||||
|
|
@ -678,6 +752,14 @@ impl Updater {
|
|||
self.min_supported = m.min_supported_version.clone();
|
||||
self.write_override(shared, &m);
|
||||
self.write_tuning(shared, &m);
|
||||
self.write_drivers(shared, &m);
|
||||
self.ui_change = Some(m.ui.clone());
|
||||
{
|
||||
// the network step: the manifest's default for a fresh install and the testnet's open flag
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.default_network = m.default_network.clone();
|
||||
st.update.testnet_open = m.testnet_open;
|
||||
}
|
||||
if let Some(e) = entry {
|
||||
if changed {
|
||||
self.file = None;
|
||||
|
|
@ -888,6 +970,10 @@ impl Updater {
|
|||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
|
||||
"-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &staged.display().to_string(), "-Version", &to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", &entry.sha256,
|
||||
]);
|
||||
// 0.3.21 (MF-11): the helper owns the return. It keeps the exe set beside the app, launches the app itself after
|
||||
// the installer, polls the new engine's api/state, restores the kept set when nothing answers, and posts one line
|
||||
// to the intake either way (the key it needs is in igneum-app.json beside the exe; nothing on the command line).
|
||||
c.args(["-Previous", &previous_dir_for(&install_dir).display().to_string(), "-Machine", &shared.runtime.id8(), "-Intake", &shared.packaged.log_intake_url, "-AppDir", &self.app_dir.display().to_string()]);
|
||||
let per_user = !under_program_files(&install_dir);
|
||||
shared.log(&format!("update: starting the installer first, the miners keep running: {} ({})", script.display(), if per_user { "per-user install, no administrator prompt" } else { "install in Program Files: Windows asks for administrator approval" }));
|
||||
if !per_user {
|
||||
|
|
@ -939,6 +1025,7 @@ impl Updater {
|
|||
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(),
|
||||
"-Previous", &previous_dir_for(&install_dir).display().to_string(), "-Machine", &shared.runtime.id8(), "-Intake", &shared.packaged.log_intake_url, "-AppDir", &self.app_dir.display().to_string(),
|
||||
]);
|
||||
spawn_detached(&mut c)?;
|
||||
Ok(())
|
||||
|
|
@ -954,7 +1041,7 @@ impl Updater {
|
|||
// ---- the threads ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
|
||||
fn live_api_from(live_page: &str) -> String {
|
||||
pub fn live_api_from(live_page: &str) -> String {
|
||||
let Some(rest) = live_page.strip_prefix("https://") else { return String::new() };
|
||||
let host = rest.split('/').next().unwrap_or("");
|
||||
if host.is_empty() { String::new() } else { format!("https://{host}/api/live") }
|
||||
|
|
@ -977,12 +1064,39 @@ fn under_program_files(dir: &Path) -> bool {
|
|||
/// Windows, per-user installs: the inbound firewall rule for igneumd.exe needs administrator approval once. Asked on
|
||||
/// the first run only, in a thread; declined or unanswered, the node still dials out and mines (other nodes cannot
|
||||
/// dial in), and it is never asked again. The administrator installer of 0.3.2 and earlier added the rule itself.
|
||||
/// Windows: the boot task (src/boot.rs) registered at every engine start when it is missing, in a thread, as the
|
||||
/// user's own task (no prompt). An account Windows refuses gets it in the one approved step with the Power Helper.
|
||||
#[cfg(windows)]
|
||||
fn boot_task_first_run(shared: &Arc<Shared>) {
|
||||
let Some(exe) = std::env::current_exe().ok() else { return };
|
||||
let exe = crate::boot::task_exe(&exe);
|
||||
if !exe.is_file() {
|
||||
return;
|
||||
}
|
||||
let root = crate::platform::fixed_data_root();
|
||||
let shared = shared.clone();
|
||||
std::thread::spawn(move || match crate::boot::ensure_registered(&exe, &root) {
|
||||
Ok(true) => shared.log(&format!("boot start: the task '{}' is registered: the engine starts at boot without a logon ({} --launch --data-root {})", crate::boot::TASK_NAME, exe.display(), root.display())),
|
||||
Ok(false) => {}
|
||||
Err(e) => shared.log(&format!("boot start: not registered ({e}); the app starts at logon only until Power control's one approved step registers it")),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn firewall_first_run(shared: &Arc<Shared>) {
|
||||
let flag = shared.runtime.app_dir.join("firewall-rule.json");
|
||||
if flag.exists() {
|
||||
return;
|
||||
}
|
||||
// 0.3.22: the rule is the installer's rights step (src/rights.rs); the app never prompts at runtime. A manifest that
|
||||
// holds the right ends it here; a manifest that lacks it (or none: an install before 0.3.22) is one log line.
|
||||
if crate::rights::held(&shared.runtime.app_dir, "firewall-node") {
|
||||
let _ = std::fs::write(&flag, json!({ "source": "rights", "at": crate::platform::unix_now() }).to_string());
|
||||
return;
|
||||
}
|
||||
shared.log(&format!("firewall: inbound rule for igneumd.exe {}", crate::rights::missing_note("firewall-node")));
|
||||
return;
|
||||
#[allow(unreachable_code)]
|
||||
let Some(install_dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) else { return };
|
||||
if under_program_files(&install_dir) {
|
||||
let _ = std::fs::write(&flag, json!({ "source": "installer", "at": crate::platform::unix_now() }).to_string());
|
||||
|
|
@ -1026,6 +1140,244 @@ fn installer_name_for(version: &str) -> String {
|
|||
format!("Igneum-Miner-Setup-{version}.exe")
|
||||
}
|
||||
|
||||
/// Windows: the folder the helper keeps the running exe set in before the installer runs, beside the app
|
||||
/// (`<install dir>.previous`, so `...\Programs\Igneum Miner.previous`). A rollback copies it back over the install folder.
|
||||
#[allow(dead_code)]
|
||||
fn previous_dir_for(install_dir: &Path) -> PathBuf {
|
||||
let name = install_dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Igneum Miner".into());
|
||||
install_dir.with_file_name(format!("{name}.previous"))
|
||||
}
|
||||
|
||||
// ---- the return after a Windows install (MF-11, 0.3.21) ------------------------------------------------------------
|
||||
//
|
||||
// PC 2 took the 0.3.19 update-now at 10:34Z on 7 October 2026 and was silent from 10:46Z. Until 0.3.21 the Windows helper's
|
||||
// job ended when the installer exited 0: the installer's own [Run] entry relaunched the app, nobody checked that an engine
|
||||
// answered, the window host never restarted an engine that died, and a second launch deferred to a surviving host through
|
||||
// its single-instance mutex. The sequence below is what the helper does from the installer's exit on, written once here
|
||||
// so a test can drive it with injected exit codes and answers, and mirrored line for line by WIN_HELPER's PowerShell.
|
||||
|
||||
/// How long the helper waits for an engine to answer api/state after a launch, and how often it asks.
|
||||
pub const RETURN_READY_S: u64 = 120;
|
||||
pub const RETURN_POLL_S: u64 = 3;
|
||||
/// The installer's marker (packaging/windows/Igneum-Miner.iss SetMarker): no relaunch while it is there and younger than
|
||||
/// this; an older one is a stale marker (an installer that died) and is ignored with a FAULT line.
|
||||
pub const INSTALL_MARKER: &str = "install-running.flag";
|
||||
pub const INSTALL_MARKER_STALE_S: u64 = 15 * 60;
|
||||
|
||||
/// May the app be launched again now? false while an installer is running (its marker present and fresh). PC 2,
|
||||
/// 7 October 2026, 20:54 BST: a job's silent install quit the engine, the window host started the old engine 10 s later,
|
||||
/// and every file stayed 0.3.21 because the host held them. `marker_age_s` is None when there is no marker.
|
||||
pub fn relaunch_allowed(marker_age_s: Option<u64>) -> bool {
|
||||
match marker_age_s {
|
||||
None => true,
|
||||
Some(age) => age > INSTALL_MARKER_STALE_S,
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker's age in seconds under the app dir, None when absent.
|
||||
pub fn install_marker_age(app_dir: &Path) -> Option<u64> {
|
||||
let m = std::fs::metadata(app_dir.join(INSTALL_MARKER)).ok()?;
|
||||
let t = m.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
|
||||
Some(crate::platform::unix_now().saturating_sub(t))
|
||||
}
|
||||
|
||||
/// One step of the helper after the installer exits.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ReturnStep {
|
||||
/// start igneum-app.exe --launch from the install folder (a detached process; the helper outlives the old engine)
|
||||
Launch,
|
||||
/// poll app.url + api/state for RETURN_READY_S; `want` is the version that must answer ("" = any engine)
|
||||
WaitReady { want: String },
|
||||
/// quit through the API, then end what is left by name (the installer's own stop order)
|
||||
StopAll,
|
||||
/// copy the kept exe set (`<install dir>.previous`) back over the install folder
|
||||
RestorePrevious,
|
||||
/// the result file for the next engine and the one intake line
|
||||
Done { ok: bool, rolled_back: bool, fault: bool, how: &'static str },
|
||||
}
|
||||
|
||||
/// What a WaitReady step saw: the version that answered, or nobody.
|
||||
pub type Answer = Option<String>;
|
||||
|
||||
/// The helper's sequence from the installer's exit code on. `answers` is consulted once per WaitReady, in order (the test
|
||||
/// injects them; the PowerShell asks the engine). `previous_kept` says whether the exe set was copied aside before the
|
||||
/// installer ran.
|
||||
pub fn return_sequence(installer_exit: i32, version: &str, previous_kept: bool, mut answers: impl FnMut(usize) -> Answer) -> Vec<ReturnStep> {
|
||||
let mut steps = vec![ReturnStep::Launch];
|
||||
let want = if installer_exit == 0 { version.to_string() } else { String::new() };
|
||||
steps.push(ReturnStep::WaitReady { want: want.clone() });
|
||||
match answers(0) {
|
||||
Some(v) if want.is_empty() || v == want => {
|
||||
steps.push(if installer_exit == 0 { ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "ok" } } else { ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "installer-failed" } });
|
||||
return steps;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// nothing (or the wrong engine) answered inside the window: back to the kept version
|
||||
steps.push(ReturnStep::StopAll);
|
||||
if previous_kept {
|
||||
steps.push(ReturnStep::RestorePrevious);
|
||||
steps.push(ReturnStep::Launch);
|
||||
steps.push(ReturnStep::WaitReady { want: String::new() });
|
||||
let how = if answers(1).is_some() { "rolled-back" } else { "rolled-back-silent" };
|
||||
steps.push(ReturnStep::Done { ok: false, rolled_back: true, fault: true, how });
|
||||
} else {
|
||||
steps.push(ReturnStep::Launch);
|
||||
steps.push(ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "relaunched" });
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// The helper before 0.3.21, for the record: the installer's exit code alone decided the result and nothing was asked
|
||||
/// of the new engine (the known-failed shape of MF-11).
|
||||
pub fn legacy_return_sequence(installer_exit: i32) -> Vec<ReturnStep> {
|
||||
if installer_exit == 0 { vec![ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "" }] } else { vec![ReturnStep::Launch, ReturnStep::Done { ok: false, rolled_back: false, fault: false, how: "" }] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod return_tests {
|
||||
use super::*;
|
||||
fn done(steps: &[ReturnStep]) -> &ReturnStep {
|
||||
steps.last().unwrap()
|
||||
}
|
||||
fn answers(list: &[Answer]) -> impl FnMut(usize) -> Answer + '_ {
|
||||
move |i| list.get(i).cloned().flatten()
|
||||
}
|
||||
|
||||
/// Known-failed first: the helper before 0.3.21 reported ok on the installer's exit 0 with nobody answering.
|
||||
#[test]
|
||||
fn the_legacy_helper_reports_ok_with_no_engine_up() {
|
||||
let steps = legacy_return_sequence(0);
|
||||
assert_eq!(steps, vec![ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "" }]);
|
||||
assert!(!steps.iter().any(|s| matches!(s, ReturnStep::WaitReady { .. })), "nothing was asked of the new engine");
|
||||
assert!(!steps.iter().any(|s| matches!(s, ReturnStep::Launch)), "the launch was the installer's, not the helper's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_ok_and_the_new_engine_answers_is_ok() {
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[Some("0.3.21".into())]));
|
||||
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "ok" }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_ok_and_nobody_answers_restores_the_previous_exe_set() {
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[None, Some("0.3.20".into())]));
|
||||
assert_eq!(steps, vec![
|
||||
ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::StopAll, ReturnStep::RestorePrevious, ReturnStep::Launch, ReturnStep::WaitReady { want: String::new() },
|
||||
ReturnStep::Done { ok: false, rolled_back: true, fault: true, how: "rolled-back" },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_engine_still_answering_after_exit_0_is_not_the_new_one() {
|
||||
// the installer said 0 but never replaced the running engine (files in use): the old version answers, the window
|
||||
// ends in a rollback to the kept set, which is the same version, and a FAULT line says so
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[Some("0.3.20".into()), Some("0.3.20".into())]));
|
||||
assert!(matches!(done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nobody_answers_twice_is_still_reported() {
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[None, None]));
|
||||
assert_eq!(*done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, how: "rolled-back-silent" });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_kept_set_the_helper_relaunches_what_is_there_and_reports() {
|
||||
let steps = return_sequence(0, "0.3.21", false, answers(&[None]));
|
||||
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::StopAll, ReturnStep::Launch, ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "relaunched" }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_installer_relaunches_the_old_version_and_reports_a_fault() {
|
||||
// exit 5 (cancelled) or 8 (files in use, a restart wanted): any engine answering is the old one, kept, with a FAULT line
|
||||
for code in [1, 5, 8] {
|
||||
let steps = return_sequence(code, "0.3.21", true, answers(&[Some("0.3.20".into())]));
|
||||
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: String::new() }, ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "installer-failed" }], "exit {code}");
|
||||
}
|
||||
let steps = return_sequence(5, "0.3.21", true, answers(&[None, Some("0.3.20".into())]));
|
||||
assert!(matches!(done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sequence_launches_before_it_waits_and_ends_in_a_done() {
|
||||
for code in [0, 1, 5, 8] {
|
||||
for kept in [true, false] {
|
||||
for a in [vec![None, None], vec![Some("0.3.21".to_string()), None], vec![None, Some("0.3.20".to_string())]] {
|
||||
let steps = return_sequence(code, "0.3.21", kept, answers(&a));
|
||||
assert_eq!(steps[0], ReturnStep::Launch);
|
||||
assert!(matches!(steps[1], ReturnStep::WaitReady { .. }));
|
||||
assert!(matches!(done(&steps), ReturnStep::Done { .. }));
|
||||
let fault = matches!(done(&steps), ReturnStep::Done { fault: true, .. });
|
||||
let ok = matches!(done(&steps), ReturnStep::Done { ok: true, .. });
|
||||
assert!(ok != fault, "a result is ok or a fault, never neither: {steps:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 0.3.22: an update applied with nobody logged on (the boot engine) returns headless: the helper's Launch runs
|
||||
/// `igneum-app.exe --launch`, which with no interactive session runs the engine itself, so the same sequence returns
|
||||
/// the app without a logon (the known-failed form first: before 0.3.22 that launch opened the window host, which has
|
||||
/// no desktop in session 0, and nothing mined until a logon)
|
||||
#[test]
|
||||
fn after_an_update_with_no_logon_the_relaunch_is_headless() {
|
||||
assert_eq!(crate::boot::legacy_launch_mode(true), crate::boot::LaunchMode::Host);
|
||||
assert_eq!(crate::boot::launch_mode(None, true), crate::boot::LaunchMode::Headless);
|
||||
let steps = return_sequence(0, "0.3.22", true, |_| Some("0.3.22".into()));
|
||||
assert_eq!(steps[0], ReturnStep::Launch, "the helper's launch is the same line; the session decides what it runs");
|
||||
assert!(matches!(steps.last().unwrap(), ReturnStep::Done { ok: true, .. }));
|
||||
}
|
||||
|
||||
/// Tonight's shape on PC 2 (7 October 2026, 20:54 BST), known-failed first: an installer running, the engine gone, and the
|
||||
/// relaunch went ahead; 0.3.23 holds while the marker is there and resumes when it clears
|
||||
#[test]
|
||||
fn no_relaunch_while_an_installer_runs_and_a_relaunch_when_it_clears() {
|
||||
assert!(relaunch_allowed(None), "the old rule in effect: nothing held the relaunch (no marker existed)");
|
||||
assert!(!relaunch_allowed(Some(0)), "an installer just wrote its marker: hold");
|
||||
assert!(!relaunch_allowed(Some(600)), "ten minutes into a slow install: still held");
|
||||
assert!(relaunch_allowed(Some(INSTALL_MARKER_STALE_S + 1)), "a marker an installer left behind when it died: ignored");
|
||||
assert!(relaunch_allowed(None), "the marker cleared at the installer's end: relaunch");
|
||||
let h = WIN_HELPER;
|
||||
let wait = h.find("function WaitInstallerClear()").expect("the helper waits");
|
||||
let launch = h.find("function Launch()").unwrap();
|
||||
assert!(wait < launch && h[launch..].contains("WaitInstallerClear"), "every launch of the helper waits for the installer first");
|
||||
assert!(h.contains("install-running.flag") && h.contains("-gt 900"), "the same marker and the same stale rule as relaunch_allowed");
|
||||
// the installer writes and clears it, and the host holds its restart on it
|
||||
let iss = include_str!("../../../packaging/windows/Igneum-Miner.iss");
|
||||
assert!(iss.contains("install-running.flag") && iss.contains("SetMarker;") && iss.contains("if CurStep = ssDone then ClearMarker") && iss.contains("SetupMutex=IgneumMinerSetup"));
|
||||
let host = include_str!("../../windows/host.cpp");
|
||||
assert!(host.contains("install-running.flag") && host.contains("installerRunning()"), "the host's restart ladder holds while the marker is there");
|
||||
}
|
||||
|
||||
/// The PowerShell mirrors the sequence: every step has its line, in order, and the installer is told the helper relaunches.
|
||||
#[test]
|
||||
fn the_windows_helper_carries_every_step() {
|
||||
let h = WIN_HELPER;
|
||||
let at = |s: &str| h.find(s).unwrap_or_else(|| panic!("WIN_HELPER lacks '{s}'"));
|
||||
assert!(at("/IGNOTA=2") > 0, "the installer must not relaunch (IGNOTA=1 is the old helpers' path)");
|
||||
assert!(h.contains(&format!("$ReadyS = {RETURN_READY_S}")) && h.contains(&format!("$PollS = {RETURN_POLL_S}")));
|
||||
let robocopy_keep = at("robocopy $InstallDir $Previous");
|
||||
let installer = at("Start-Process -FilePath $Installer"); // console: a test marker, not a spawn (the helper line above it carries the comment)
|
||||
let launch = at("function Launch()");
|
||||
let wait = at("function WaitReady(");
|
||||
let stop = at("function StopAll()");
|
||||
let restore = at("robocopy $Previous $InstallDir");
|
||||
let report = at("function Report(");
|
||||
assert!(launch < installer && wait < installer && stop < installer && report < installer, "the functions are defined before the installer runs");
|
||||
assert!(robocopy_keep < installer && restore < installer, "the keep and the restore are functions defined before the installer line");
|
||||
assert!(at("$kept = KeepPrevious") < installer, "the exe set is kept before the installer runs");
|
||||
assert!(at("Comeback $code $kept") > installer, "the return runs after the installer");
|
||||
for marker in ["'ok'", "'installer-failed'", "'rolled-back'", "'rolled-back-silent'", "'relaunched'"] {
|
||||
assert!(h.contains(marker), "the helper never writes return={marker}");
|
||||
}
|
||||
assert!(h.contains("FAULT update-return:"), "the fault line");
|
||||
assert!(h.contains("update-return: ok"), "the ok line");
|
||||
assert!(h.contains("api/state"), "readiness is the engine's own answer");
|
||||
assert!(!h.contains("-IntakeKey"), "no key travels on a command line (R4.3.8)");
|
||||
}
|
||||
}
|
||||
|
||||
fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
|
||||
let mut c = Command::new(crate::platform::tool("curl"));
|
||||
c.args(args);
|
||||
|
|
@ -1261,60 +1613,194 @@ case "$MODE" in
|
|||
esac
|
||||
"#;
|
||||
|
||||
#[cfg(windows)]
|
||||
/// The Windows helper. Not cfg-gated so the return test above can read it on every platform.
|
||||
#[allow(dead_code)]
|
||||
const WIN_HELPER: &str = r#"# Igneum Miner update helper, written by the engine (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>
|
||||
# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex> -Previous <folder> -Machine <id8> -Intake <url> -AppDir <app data>
|
||||
# The installer runs FIRST, while the engine keeps mining (4 October 2026: two unattended PCs sat stopped at an
|
||||
# administrator prompt nobody could click). A per-user installer (0.3.3 and later, 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: miners first, then the node), 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 and the engine shows "waits for the next time someone is at this PC". 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 = '')
|
||||
# its PrepareToInstall step stop the engine (api/quit: miners first, then the node) and replace the files. A declined,
|
||||
# timed-out or unanswered prompt leaves the engine running: the result says deferred:true and the engine shows "waits for
|
||||
# the next time someone is at this PC".
|
||||
# From 0.3.21 (MF-11, 7 October 2026) this helper owns the return: it keeps the running exe set beside the app before the
|
||||
# installer runs, starts the app itself afterwards (/IGNOTA=2 tells the installer not to), waits for an engine to answer
|
||||
# api/state with the new version, restores the kept set when nothing answers inside the window, and posts one line to
|
||||
# the log intake either way (the key is read from igneum-app.json beside the exe, never from the command line). The
|
||||
# sequence is src/ota.rs return_sequence(), tested there with injected exit codes; this file mirrors it step for step.
|
||||
param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '', [string]$Previous = '', [string]$Machine = '', [string]$Intake = '', [string]$AppDir = '')
|
||||
$log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log'
|
||||
if (-not $AppDir) { $AppDir = Split-Path -Parent $Result }
|
||||
$ReadyS = 120
|
||||
$PollS = 3
|
||||
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) }
|
||||
function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred, [string]$ret, [int]$readyS) {
|
||||
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s); 'return' = $ret; ready_s = $readyS }
|
||||
($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 'igneum-app.exe'
|
||||
# console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens
|
||||
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 }
|
||||
function AppUrl() {
|
||||
$f = Join-Path $AppDir 'app.url'
|
||||
if (Test-Path $f) { return (Get-Content $f -Raw).Trim() }
|
||||
return ''
|
||||
}
|
||||
Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps mining until the installer runs)"
|
||||
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false; exit 1 }
|
||||
function AppVersion() {
|
||||
# the engine's own answer: GET <app.url>api/state and its version field (the URL file is rewritten by every start)
|
||||
$u = AppUrl
|
||||
if (-not $u) { return '' }
|
||||
try { $r = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$r.version } catch { return '' }
|
||||
}
|
||||
function WaitReady([string]$want, [int]$limitS) {
|
||||
# the seconds until an engine answered with the wanted version (any version when $want is empty); -1 when none did
|
||||
$t0 = Get-Date
|
||||
while (((Get-Date) - $t0).TotalSeconds -lt $limitS) {
|
||||
$v = AppVersion
|
||||
if ($v -and (($want -eq '') -or ($v -eq $want))) { return [int]((Get-Date) - $t0).TotalSeconds }
|
||||
Start-Sleep -Seconds $PollS
|
||||
}
|
||||
return -1
|
||||
}
|
||||
function WaitInstallerClear() {
|
||||
# no relaunch while another installer runs (its marker beside app.url, written by the installer's PrepareToInstall and
|
||||
# removed at its end); a marker older than 15 min is an installer that died: ignored with a FAULT line (src/ota.rs relaunch_allowed)
|
||||
$m = Join-Path $AppDir 'install-running.flag'
|
||||
$t0 = Get-Date
|
||||
while (Test-Path $m) {
|
||||
$age = ((Get-Date) - (Get-Item $m).LastWriteTime).TotalSeconds
|
||||
if ($age -gt 900) { Log ('FAULT update-return: a stale installer marker (' + [int]$age + ' s old) was ignored'); break }
|
||||
if (((Get-Date) - $t0).TotalMinutes -gt 20) { Log 'FAULT update-return: an installer marker stayed 20 min; relaunching anyway'; break }
|
||||
Log 'relaunch held: another installer is running (install-running.flag present)'
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
}
|
||||
function Launch() {
|
||||
$exe = Join-Path $InstallDir 'igneum-app.exe'
|
||||
if (-not (Test-Path $exe)) { Log ("nothing to start: " + $exe + " is missing"); return $false }
|
||||
WaitInstallerClear
|
||||
Log ("starting " + $exe + " --launch")
|
||||
# console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens
|
||||
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null
|
||||
return $true
|
||||
}
|
||||
function StopAll() {
|
||||
# the quit through the API first (miners, then the node), then whatever is left by name: the installer's own order
|
||||
$u = AppUrl
|
||||
if ($u) { try { Invoke-WebRequest -Uri ($u + 'api/quit') -Method POST -Body '{}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null } catch { } }
|
||||
$deadline = (Get-Date).AddSeconds(40)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (@(Get-Process -Name 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue).Count -eq 0) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
foreach ($n in @('Igneum Miner', 'igneum-app', 'igneum-miner', 'igneum-worker-cuda', 'igneum-worker-opencl', 'igneumd')) {
|
||||
Get-Process -Name $n -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
function Report([string]$line) {
|
||||
# one line to the log intake, label fault-win-<id8>, as site/api/log.mjs expects; the key is the one the installed
|
||||
# app carries (igneum-app.json beside the exe, or the kept copy); nothing to post with is logged, never fatal
|
||||
Log $line
|
||||
if (-not $Intake -or -not $Machine) { return }
|
||||
$cfg = Join-Path $InstallDir 'igneum-app.json'
|
||||
if (-not (Test-Path $cfg) -and $Previous) { $cfg = Join-Path $Previous 'igneum-app.json' }
|
||||
if (-not (Test-Path $cfg)) { Log 'no igneum-app.json to read the intake key from; the line stays in this log'; return }
|
||||
try {
|
||||
$key = [string](Get-Content $cfg -Raw | ConvertFrom-Json).log_intake_key
|
||||
if (-not $key) { Log 'igneum-app.json carries no intake key'; return }
|
||||
$o = @{ label = ('fault-win-' + $Machine); machine = ($env:COMPUTERNAME + '-' + $Machine); run_id = ('update-return-' + $Version); lines = ("IGNEUM-APP version=" + $Version + " machine=" + $Machine + " platform=windows node=?`n" + $line) }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes(($o | ConvertTo-Json -Compress))
|
||||
Invoke-RestMethod -Method Post -Uri $Intake -Headers @{ 'x-igneum-key' = $key } -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 30 | Out-Null
|
||||
Log 'intake line posted'
|
||||
} catch { Log ('intake post failed: ' + $_.Exception.Message) }
|
||||
}
|
||||
function KeepPrevious() {
|
||||
# the running exe set beside the app, what a rollback restores; packs, build and dist are rebuilt or unneeded
|
||||
if (-not $Previous) { return $false }
|
||||
try {
|
||||
& robocopy $InstallDir $Previous /MIR /XD packs build dist /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -lt 8 -and (Test-Path (Join-Path $Previous 'igneum-app.exe'))) { Log ("previous version kept at " + $Previous); return $true }
|
||||
Log ("robocopy could not keep the previous version (exit " + $LASTEXITCODE + ")")
|
||||
} catch { Log ('could not keep the previous version: ' + $_.Exception.Message) }
|
||||
return $false
|
||||
}
|
||||
function RestorePrevious() {
|
||||
if (-not $Previous -or -not (Test-Path (Join-Path $Previous 'igneum-app.exe'))) { return $false }
|
||||
try {
|
||||
& robocopy $Previous $InstallDir /MIR /XD packs build dist /R:2 /W:2 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -lt 8) { Log 'previous version restored over the install folder'; return $true }
|
||||
Log ("robocopy could not restore the previous version (exit " + $LASTEXITCODE + ")")
|
||||
} catch { Log ('could not restore the previous version: ' + $_.Exception.Message) }
|
||||
return $false
|
||||
}
|
||||
function Comeback([int]$code, [bool]$kept) {
|
||||
# src/ota.rs return_sequence(): Launch, WaitReady, then Done or StopAll, RestorePrevious, Launch, WaitReady, Done
|
||||
if (-not (EngineAlive)) { Launch | Out-Null } else { Log 'the engine is still up after the installer (it did not stop it)' }
|
||||
$want = ''
|
||||
if ($code -eq 0) { $want = $Version }
|
||||
$ready = WaitReady $want $ReadyS
|
||||
if ($ready -ge 0) {
|
||||
if ($code -eq 0) {
|
||||
Done $true '' $false $false 'ok' $ready
|
||||
Report ("update-return: ok " + $Version + " up in " + $ready + " s")
|
||||
exit 0
|
||||
}
|
||||
Done $false ("the installer exited with code " + $code + " (see ota-setup.log); the previous version answers again") $false $false 'installer-failed' $ready
|
||||
Report ("FAULT update-return: the installer of " + $Version + " exited with code " + $code + "; the previous version answered again after " + $ready + " s")
|
||||
exit 1
|
||||
}
|
||||
Log ("no engine answered api/state with '" + $want + "' inside " + $ReadyS + " s; back to the previous version")
|
||||
StopAll
|
||||
if ($kept -and (RestorePrevious)) {
|
||||
Launch | Out-Null
|
||||
$r2 = WaitReady '' $ReadyS
|
||||
if ($r2 -ge 0) {
|
||||
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; the previous version was restored") $true $false 'rolled-back' $r2
|
||||
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s after the install; the previous exe set was restored and answers after " + $r2 + " s")
|
||||
exit 1
|
||||
}
|
||||
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; the previous version was restored but did not answer either") $true $false 'rolled-back-silent' -1
|
||||
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s; the previous exe set was restored and did not answer inside " + $ReadyS + " s either; a hand start is needed on this PC")
|
||||
exit 1
|
||||
}
|
||||
Launch | Out-Null
|
||||
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; no kept version to restore; what is installed was started again") $false $false 'relaunched' -1
|
||||
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s and no previous exe set was kept; what is installed was started again")
|
||||
exit 1
|
||||
}
|
||||
Log "$Mode : engine $EnginePid installer '$Installer' version $Version previous '$Previous' (the engine keeps mining until the installer runs)"
|
||||
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false '' -1; exit 1 }
|
||||
# the installer is hashed again right before it runs (R4.3.5)
|
||||
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false; exit 1 }
|
||||
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false '' -1; 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 }
|
||||
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 '' -1; exit 1 }
|
||||
Log 'installer sha256 verified'
|
||||
$kept = $false
|
||||
if ($Mode -eq 'apply') { $kept = KeepPrevious }
|
||||
$setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log'
|
||||
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"'))
|
||||
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=2', ('/LOG="' + $setupLog + '"'))
|
||||
$code = -1
|
||||
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 mining
|
||||
# console: the Inno Setup installer is a GUI program (no console), /VERYSILENT shows nothing
|
||||
$p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru
|
||||
if ($p.ExitCode -eq 0) {
|
||||
if ($Mode -eq 'rollback') { Done $false "Igneum Miner $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
|
||||
$code = $p.ExitCode
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
Log ("installer did not run: " + $msg)
|
||||
Log 'OTA: waiting for administrator approval; the engine keeps mining; the update waits for the next time someone is at this PC'
|
||||
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true
|
||||
Relaunch
|
||||
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true '' -1
|
||||
if (-not (EngineAlive)) { Launch | Out-Null }
|
||||
exit 1
|
||||
}
|
||||
Log ("installer exit " + $code)
|
||||
if ($Mode -eq 'rollback') {
|
||||
# the kept installer of the previous version ran: the same return, reported as the rollback it is
|
||||
if (-not (EngineAlive)) { Launch | Out-Null }
|
||||
$r = WaitReady '' $ReadyS
|
||||
Done $false ("Igneum Miner " + $Version + " did not stay up twice; the previous version was reinstalled") $true $false 'rolled-back' $r
|
||||
Report ("FAULT update-return: " + $Version + " did not stay up twice; the previous version was reinstalled (installer exit " + $code + ", answered after " + $r + " s)")
|
||||
exit 1
|
||||
}
|
||||
Comeback $code $kept
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub fn tool(name: &str) -> PathBuf {
|
|||
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"),
|
||||
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" | "wevtutil" | "robocopy" => 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");
|
||||
|
|
@ -206,6 +206,17 @@ pub fn open_url(url: &str) {
|
|||
let _ = Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
|
||||
/// Shows a file in the system's file browser (the saved block card): Finder with the file selected, Explorer with
|
||||
/// the file selected, the folder on other systems.
|
||||
pub fn reveal_file(path: &std::path::Path) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = Command::new(tool("open")).arg("-R").arg(path).spawn();
|
||||
#[cfg(windows)]
|
||||
let _ = quiet(&mut Command::new(tool("explorer"))).arg(format!("/select,{}", path.display())).spawn();
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let _ = Command::new("xdg-open").arg(path.parent().unwrap_or(path)).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 {
|
||||
|
|
@ -257,6 +268,49 @@ pub fn keep_awake_tick() {
|
|||
}
|
||||
|
||||
/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through
|
||||
/// Every `igneum-miner` process on this machine with its command line: (pid, command line). Windows reads
|
||||
/// Win32_Process through PowerShell; unix reads `ps`. An empty list when the tool fails (the caller kills nothing).
|
||||
pub fn miner_processes() -> Vec<(u32, String)> {
|
||||
let out = if cfg!(windows) {
|
||||
let mut c = std::process::Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='igneum-miner.exe'\" | ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }"]);
|
||||
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(20))
|
||||
} else {
|
||||
let mut c = std::process::Command::new("ps");
|
||||
c.args(["-eo", "pid=,args="]);
|
||||
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(10))
|
||||
};
|
||||
let Some(out) = out else { return vec![] };
|
||||
let mut v = Vec::new();
|
||||
for l in out.lines() {
|
||||
let l = l.trim();
|
||||
let (pid, cmd) = if cfg!(windows) {
|
||||
let Some((p, c)) = l.split_once('|') else { continue };
|
||||
(p.trim(), c.trim())
|
||||
} else {
|
||||
let Some((p, c)) = l.split_once(' ') else { continue };
|
||||
(p.trim(), c.trim())
|
||||
};
|
||||
let Ok(pid) = pid.parse::<u32>() else { continue };
|
||||
if !cfg!(windows) && !(cmd.contains("igneum-miner ") || cmd.ends_with("igneum-miner")) {
|
||||
continue;
|
||||
}
|
||||
if cmd.contains(" mine ") {
|
||||
v.push((pid, cmd.to_string()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Ends one process by pid (Windows: taskkill /T /F; unix: SIGKILL).
|
||||
pub fn kill_pid(pid: u32) {
|
||||
if cfg!(windows) {
|
||||
let _ = quiet(&mut std::process::Command::new(tool("taskkill"))).args(["/PID", &pid.to_string(), "/T", "/F"]).output();
|
||||
} else {
|
||||
let _ = quiet(&mut std::process::Command::new("kill")).args(["-9", &pid.to_string()]).output();
|
||||
}
|
||||
}
|
||||
|
||||
/// std (what today's launcher does with taskkill /F).
|
||||
pub fn terminate(child: &mut std::process::Child) {
|
||||
#[cfg(unix)]
|
||||
|
|
|
|||
|
|
@ -35,6 +35,46 @@ use std::time::{Duration, Instant};
|
|||
pub const TASK_NAME: &str = "Igneum Power Helper";
|
||||
/// The helper ends after this long without a new command.
|
||||
pub const IDLE_S: u64 = 20 * 60;
|
||||
/// The heartbeat file the helper refreshes every poll; an engine judges "the helper runs" by it, never by a flag
|
||||
/// of its own (6 October 2026, PC 1: the flag said yes after the helper's idle exit, and commands went to nobody).
|
||||
pub const ALIVE_FILE: &str = "helper.alive";
|
||||
/// A heartbeat older than this is a dead helper.
|
||||
pub const ALIVE_MAX_S: u64 = 4;
|
||||
|
||||
/// Writes the heartbeat: the unix time, as text.
|
||||
pub fn beat(dir: &Path, now: u64) {
|
||||
let _ = std::fs::write(dir.join(ALIVE_FILE), now.to_string());
|
||||
}
|
||||
|
||||
/// Reads a heartbeat: is the helper alive at `now`?
|
||||
pub fn alive_at(text: &str, now: u64) -> bool {
|
||||
text.trim().parse::<u64>().map(|t| now.saturating_sub(t) <= ALIVE_MAX_S).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Is the helper that reads `dir` alive now?
|
||||
pub fn alive(dir: &Path) -> bool {
|
||||
std::fs::read_to_string(dir.join(ALIVE_FILE)).map(|t| alive_at(&t, crate::platform::unix_now())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Starts the task unless the helper already runs, then waits for a fresh heartbeat (up to `wait`). The engine
|
||||
/// writes a command only after this says Ok: the helper skips what the file held before its start (a stale quit
|
||||
/// is not a command), so a command written before the start would be skipped with it (6 October 2026, 17:55:25Z
|
||||
/// on PC 1: the first request of the first tune).
|
||||
pub fn ensure_running(dir: &Path, wait: Duration) -> Result<(), String> {
|
||||
if alive(dir) {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
start()?;
|
||||
let until = Instant::now() + wait;
|
||||
while Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
if alive(dir) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("the Igneum Power Helper task gave no heartbeat within {} s of its start", wait.as_secs()))
|
||||
}
|
||||
|
||||
/// One parsed command from cmd.txt.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -51,6 +91,9 @@ pub enum HelperCmd {
|
|||
/// re-point the task at the installed exe (no path argument: the helper finds the install folder itself, so a
|
||||
/// writer of cmd.txt can never choose what runs elevated); 6 October 2026, run 6 registered a scratch copy
|
||||
Reregister,
|
||||
/// the unattended driver install (src/driverinstall.rs): a vendor WORD only; the helper resolves the file, hash,
|
||||
/// signer and arguments from the signed table itself
|
||||
Driver(String),
|
||||
}
|
||||
|
||||
/// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and
|
||||
|
|
@ -75,10 +118,42 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
|
|||
[_, "rmc"] => Some((seq, HelperCmd::MemReset)),
|
||||
[_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))),
|
||||
[_, "reregister"] => Some((seq, HelperCmd::Reregister)),
|
||||
[_, "driver", v] if crate::driverinstall::vendor_ok(v) => Some((seq, HelperCmd::Driver(v.to_string()))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sequence number a command line carries on the wire. The helper runs a line only when its number is above
|
||||
/// every number it has seen (its `last_seq`, seeded from the file at its start), so every writer must draw from
|
||||
/// ONE monotonic space: the unix time modulo a million (six digits, what `parse_line` accepts), plus a small
|
||||
/// offset per line. (F) 6 October 2026, 22:19Z on PC 1: the tune path wrote its request index ("00 dev 1", "01 pl
|
||||
/// 160", ...) while the cap path had written 305327 and up, so the helper skipped every tune command as stale and
|
||||
/// both climbs stopped on "the helper did not run sequence 1 within 15 s". Wraps every 11.6 days; the helper's
|
||||
/// idle exit (20 minutes) re-seeds it from the file, so a wrap costs at most one tune step.
|
||||
pub fn wire_seq() -> u64 {
|
||||
crate::platform::unix_now() % 999_990
|
||||
}
|
||||
|
||||
/// How many leading lines of `text` are still the ones the helper saw at its start: `skip` while the file only grew
|
||||
/// and its first `skip` lines read as before; 0 when the file shrank OR was rewritten (the same count, other text).
|
||||
/// PC 2, 7 October 2026 (every tune refused since the 14:35Z boot, "helper started" three times with no command run):
|
||||
/// the engine writes its four command lines with fs::write over a stale four-line file, so the count never dropped,
|
||||
/// the skip never reset, and the helper read every new command as "present at start".
|
||||
pub fn effective_skip(initial: &str, text: &str, skip: usize) -> usize {
|
||||
if text.lines().count() < skip {
|
||||
return 0;
|
||||
}
|
||||
let same_prefix = text.lines().take(skip).eq(initial.lines().take(skip));
|
||||
if same_prefix { skip } else { 0 }
|
||||
}
|
||||
|
||||
/// The commands a helper acts on: the lines added after its start (`skip` = the line count at the start, 0 again
|
||||
/// when the file shrank or was rewritten: effective_skip). A stale `quit` or `remove` from an earlier engine is never a command.
|
||||
pub fn commands_after(text: &str, skip: usize) -> Vec<(u64, HelperCmd)> {
|
||||
let skip = if text.lines().count() < skip { 0 } else { skip };
|
||||
text.lines().skip(skip).filter_map(parse_line).collect()
|
||||
}
|
||||
|
||||
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
|
||||
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
|
||||
match c {
|
||||
|
|
@ -147,9 +222,12 @@ pub fn start_command() -> String {
|
|||
format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0")
|
||||
}
|
||||
|
||||
/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1).
|
||||
/// The PowerShell that says whether the task is registered AND its action's exe is still there (exit 0), or not (exit 1).
|
||||
/// A task whose exe has gone (the stale-task class of 7 October 2026: a logon task pointing at a folder that was not
|
||||
/// there any more) reads as not registered, so the next cap apply with Power control on registers it again through the
|
||||
/// one approved step instead of starting a task that cannot run.
|
||||
pub fn query_command() -> String {
|
||||
format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}")
|
||||
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}")
|
||||
}
|
||||
|
||||
/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left.
|
||||
|
|
@ -195,20 +273,29 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
}
|
||||
};
|
||||
log("helper started (scheduled task, elevated)");
|
||||
// a stale file from an earlier run is not a command: only lines after the start count
|
||||
let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0);
|
||||
let mut last_text = String::new();
|
||||
beat(dir, crate::platform::unix_now());
|
||||
// a stale file from an earlier run is not a command: only lines ADDED after the start count. 6 October 2026,
|
||||
// PC 1 17:55:55Z: a helper that started after an engine had written `quit` read that line and exited in the
|
||||
// same second, and every later start did the same; so the lines present at the start are skipped whole
|
||||
// (quit and remove included), and a file that shrinks starts the count again
|
||||
let initial = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||
let mut last_seq: u64 = initial.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
|
||||
let mut skip = initial.lines().count();
|
||||
let initial_text = initial.clone();
|
||||
let mut last_text = initial;
|
||||
let mut dev = "0".to_string();
|
||||
let mut idle = Instant::now();
|
||||
let smi = crate::platform::tool("nvidia-smi");
|
||||
loop {
|
||||
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||
if text != last_text {
|
||||
skip = effective_skip(&initial_text, &text, skip);
|
||||
last_text = text.clone();
|
||||
for (seq, c) in text.lines().filter_map(parse_line) {
|
||||
for (seq, c) in commands_after(&text, skip) {
|
||||
match c {
|
||||
HelperCmd::Quit => {
|
||||
log("quit");
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
return 0;
|
||||
}
|
||||
HelperCmd::Remove => {
|
||||
|
|
@ -240,6 +327,11 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
let now = q.output().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
||||
log(&format!("{seq} reregister {}: the task now runs {now}", if ok { "ok" } else { "failed" }));
|
||||
}
|
||||
HelperCmd::Driver(v) => {
|
||||
last_seq = seq;
|
||||
crate::driverinstall::run_in_helper(dir, seq, &v, &log);
|
||||
idle = Instant::now();
|
||||
}
|
||||
HelperCmd::Dev(d) => {
|
||||
last_seq = seq;
|
||||
idle = Instant::now();
|
||||
|
|
@ -261,8 +353,10 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
}
|
||||
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
|
||||
log("idle 20 min: exit (the engine starts the task again when it needs it)");
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
return 0;
|
||||
}
|
||||
beat(dir, crate::platform::unix_now());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
|
@ -282,6 +376,20 @@ pub fn helper_dir() -> PathBuf {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Known-failed first (7 October 2026, 19:5x BST): the helper knew no driver verb, so a driver install needed an
|
||||
/// elevated prompt. The verb carries a vendor WORD only: the helper resolves the file, the hash, the signer and the
|
||||
/// arguments from the signed table itself, so a writer of cmd.txt can never choose what runs elevated.
|
||||
#[test]
|
||||
fn the_helper_takes_a_driver_verb_for_a_vendor_word_and_nothing_else() {
|
||||
assert_eq!(parse_line("305327 driver intel"), Some((305327, HelperCmd::Driver("intel".into()))));
|
||||
assert_eq!(parse_line("305328 driver nvidia"), Some((305328, HelperCmd::Driver("nvidia".into()))));
|
||||
assert_eq!(parse_line("305329 driver amd"), Some((305329, HelperCmd::Driver("amd".into()))));
|
||||
assert_eq!(parse_line("305330 driver C:\\evil.exe"), None, "a path is not a vendor word");
|
||||
assert_eq!(parse_line("305331 driver apple"), None, "no installer for that vendor");
|
||||
assert_eq!(parse_line("driver intel"), None, "a sequence number is required");
|
||||
assert_eq!(smi_args("0", &HelperCmd::Driver("intel".into())), None, "a driver verb is never an nvidia-smi call");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_fixed_verbs_with_digit_arguments_parse() {
|
||||
assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460))));
|
||||
|
|
@ -296,6 +404,32 @@ mod tests {
|
|||
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
|
||||
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
|
||||
assert_eq!(parse_line("12 reregister"), Some((12, HelperCmd::Reregister)));
|
||||
// a stale quit present at the start is skipped; a quit added later counts; a rewritten (shorter) file counts whole
|
||||
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n", 3), vec![]);
|
||||
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n7 dev 1\n", 3), vec![(7, HelperCmd::Dev("1".into()))]);
|
||||
assert_eq!(commands_after("quit\n", 3), vec![(0, HelperCmd::Quit)]);
|
||||
// PC 2, 7 October 2026: the known-failed shape first. A stale four-line file at the helper's start, then the engine's
|
||||
// four-line rewrite: the count never dropped, so the old rule skipped every new command
|
||||
let stale = "401000 dev 0\n401001 pl 460\n401002 rgc\n401003 rmc\n";
|
||||
let fresh = "401888 dev 0\n401889 pl 575\n401890 rgc\n401891 rmc\n";
|
||||
assert_eq!(commands_after(fresh, 4), vec![], "the old rule: a same-length rewrite is invisible");
|
||||
assert_eq!(effective_skip(stale, fresh, 4), 0, "a rewrite resets the skip");
|
||||
assert_eq!(commands_after(fresh, effective_skip(stale, fresh, 4)).len(), 4, "every new command runs");
|
||||
// the file only grew: the stale prefix stays skipped (a stale quit at start is never a command)
|
||||
let grown = format!("{stale}401888 dev 0\n");
|
||||
assert_eq!(effective_skip(stale, &grown, 4), 4);
|
||||
assert_eq!(commands_after(&grown, effective_skip(stale, &grown, 4)), vec![(401888, HelperCmd::Dev("0".into()))]);
|
||||
assert_eq!(effective_skip("quit\n", "quit\n401888 dev 0\n", 1), 1, "a stale quit stays skipped while the file only grows");
|
||||
assert_eq!(effective_skip(stale, "401888 dev 0\n", 4), 0, "a shorter file resets as before");
|
||||
assert_eq!(effective_skip("", fresh, 0), 0);
|
||||
// the heartbeat: fresh within ALIVE_MAX_S, dead after, dead when unreadable
|
||||
// (F) every wire number parses (six digits at most) and leaves room for the four lines of a tune step
|
||||
let w = wire_seq();
|
||||
assert!(w + 3 <= 999_999 && parse_line(&format!("{} rmc", w + 3)).is_some());
|
||||
assert!(alive_at("1791309325", 1791309325 + ALIVE_MAX_S));
|
||||
assert!(!alive_at("1791309325", 1791309325 + ALIVE_MAX_S + 1));
|
||||
assert!(!alive_at("", 1791309325));
|
||||
assert!(!alive_at("soon", 1791309325));
|
||||
assert_eq!(parse_line("12 reregister C:\\evil.exe"), None, "no path argument: the helper picks the install folder itself");
|
||||
assert_eq!(smi_args("0", &HelperCmd::Reregister), None);
|
||||
assert!(readback_command().contains("Actions[0].Execute"));
|
||||
|
|
@ -328,6 +462,14 @@ mod tests {
|
|||
assert!(!s.contains("-Trigger"), "no trigger: only the app starts it");
|
||||
assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}");
|
||||
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
|
||||
// no window at logon or at a start (the project lead, 7 October 2026, PC 2's Terminal window): the action is the app's own exe,
|
||||
// a windows-subsystem program with no console, never powershell.exe, cmd.exe or a `start` of a batch file
|
||||
assert!(s.contains("-Execute 'C:\\p\\igneum-app.exe' -Argument '--power-helper'") || s.contains("-Argument '--power-helper'"), "{s}");
|
||||
assert!(!s.contains("powershell.exe") && !s.contains("cmd.exe") && !s.contains("cmd /c start"), "the task's action must be the exe itself: {s}");
|
||||
assert!(s.contains("-Hidden"), "the task is hidden in the scheduler too");
|
||||
// the registered probe also wants the action's exe on disk and the task enabled (the stale-task class)
|
||||
let q = query_command();
|
||||
assert!(q.contains("Test-Path (($t.Actions[0].Execute).Trim") && q.contains("$t.State -ne 'Disabled'") && q.contains("exit 1"), "{q}");
|
||||
// a quote in the path cannot break out of the literal
|
||||
let q = register_script(Path::new(r"C:\it's\igneum-app.exe"));
|
||||
assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}");
|
||||
|
|
|
|||
|
|
@ -26,6 +26,39 @@ pub const MIN_VRAM_MB_PROVE_ONLY: u64 = 23_552;
|
|||
/// miner), the devnet's shard until its fee switch at DAA 210,000; `nvidia-smi` reports 32,607 for the RTX 5090.
|
||||
pub const VRAM_MB_PROTOTYPE_SHARD: u64 = 31_000;
|
||||
|
||||
/// Main's rule (7 October 2026, the fleet's prover roll): a 10 GB card never completes the compressed step on the
|
||||
/// 0.3.17 pair (p1-3080: 29 of 29 proofs died at the memory wall, device_used 9,859 of 9,885 MiB, 0 paid in 2,167
|
||||
/// claims), so the prover REFUSES to start, Settings or not, unless an NVIDIA card of 12 GB or more is present, with
|
||||
/// the reason shown; the lower-memory SP1 threshold is being measured on a rented 3080 and moves this line when
|
||||
/// it lands. `nvidia-smi` reports a 12 GB card at about 12,208 MiB, a 10 GB card at about 10,240.
|
||||
pub const MIN_VRAM_MB_PROVE_ANY: u64 = 11_800;
|
||||
/// The sentence the Proving tile and the log carry when the rule refuses (verbatim from main; the kit's box-prover
|
||||
/// says the same).
|
||||
pub const PROVE_UNDER_12GB_LINE: &str = "proving needs a 12 GB card; mining continues";
|
||||
|
||||
/// The rule above as one question: None when a present NVIDIA card of 12 GB or more exists (or when no NVIDIA card
|
||||
/// is present at all, since then the CPU and Apple paths decide), Some(the sentence) when every present NVIDIA card is
|
||||
/// under 12 GB.
|
||||
/// The sentence the tile carries while the switch holds the miner off.
|
||||
pub fn prove_instead_line(cards: &[CardState]) -> String {
|
||||
let names: Vec<String> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && c.vram_mb < MIN_VRAM_MB_PROVE_ANY).map(|c| format!("{} ({} GB)", c.name, gb(c.vram_mb))).collect();
|
||||
format!("proving instead of mining on {} (a card under 12 GB holds one, not both)", names.join(", "))
|
||||
}
|
||||
|
||||
/// The keys of the cards the switch holds off: every present NVIDIA card under 12 GB, and only when the rule refuses
|
||||
/// (no present NVIDIA card at or above 12 GB); with a bigger card present the small ones keep mining and nothing is held.
|
||||
pub fn prove_instead_cards(cards: &[CardState]) -> Vec<String> {
|
||||
if prove_refused_under_12gb(cards).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && c.vram_mb < MIN_VRAM_MB_PROVE_ANY).map(|c| c.key.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn prove_refused_under_12gb(cards: &[CardState]) -> Option<&'static str> {
|
||||
let nvidia: Vec<&CardState> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present()).collect();
|
||||
if nvidia.is_empty() || nvidia.iter().any(|c| c.vram_mb >= MIN_VRAM_MB_PROVE_ANY) { None } else { Some(PROVE_UNDER_12GB_LINE) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Decision {
|
||||
pub on: bool,
|
||||
|
|
@ -166,4 +199,78 @@ mod tests {
|
|||
let d = decide(&[idle("nvidia", "RTX 4090", 24_564), card("nvidia", "RTX 5090", 32_607)], "linux", None, None);
|
||||
assert!(d.line.contains("RTX 5090 (32 GB, mining too)"), "{}", d.line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prover_refuses_every_nvidia_card_under_12gb_and_says_why() {
|
||||
let c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080.clone()]), Some(PROVE_UNDER_12GB_LINE));
|
||||
// a 12 GB card beside it lifts the refusal (nvidia-smi reports a 12 GB card at 12,208)
|
||||
let c3080_12 = card("nvidia", "NVIDIA GeForce RTX 3080 12GB", 12_208);
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3080_12]), None);
|
||||
// an idle 24 GB card lifts it too
|
||||
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3090]), None);
|
||||
// no NVIDIA card at all: not this rule's question (Apple and AMD have their own sentences)
|
||||
assert_eq!(prove_refused_under_12gb(&[card("amd", "AMD Radeon RX 9070 XT", 16_368), card("apple", "Apple M5 Max", 0)]), None);
|
||||
// a card that left the machine does not count either way
|
||||
let mut gone = card("nvidia", "NVIDIA GeForce RTX 3090", 24_564); gone.removed_at = 1.0;
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080, gone]), Some(PROVE_UNDER_12GB_LINE));
|
||||
assert_eq!(PROVE_UNDER_12GB_LINE, "proving needs a 12 GB card; mining continues");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prove_instead_holds_only_the_small_cards_and_only_when_the_rule_refuses() {
|
||||
let mut c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
|
||||
c3080.key = "nvidia:0:NVIDIA GeForce RTX 3080".into();
|
||||
assert_eq!(prove_instead_cards(&[c3080.clone()]), vec!["nvidia:0:NVIDIA GeForce RTX 3080".to_string()]);
|
||||
assert_eq!(prove_instead_line(&[c3080.clone()]), "proving instead of mining on NVIDIA GeForce RTX 3080 (10 GB) (a card under 12 GB holds one, not both)");
|
||||
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
|
||||
assert!(prove_instead_cards(&[c3080.clone(), c3090]).is_empty());
|
||||
assert!(prove_instead_cards(&[card("amd", "AMD Radeon RX 9070 XT", 16_368)]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// MF-10 (docs/plans/miner-faults.md, 7 October 2026): a `sp1-gpu-server` built for another card's architecture fails
|
||||
/// every proof in 12 s with `CudaRustError: named symbol not found` and nothing notices. `card_cap` is the card's
|
||||
/// compute capability as nvidia-smi prints it ("12.0", "8.9", "8.6"); `server_archs` are the `sm_NN` words found in
|
||||
/// the server binary (empty = unknown, which passes: an SDK server may carry no arch string). Some(the sentence) when
|
||||
/// the server names architectures and none is the card's.
|
||||
pub fn server_mismatch(card_cap: &str, server_archs: &[String]) -> Option<String> {
|
||||
let cap = card_cap.trim();
|
||||
if cap.is_empty() || server_archs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let want = format!("sm_{}", cap.replace('.', ""));
|
||||
if server_archs.iter().any(|a| a.trim() == want) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("the proving server is built for {} and this card is {want} (compute capability {cap}); proving stays off here until a server for this card is installed", server_archs.join(", ")))
|
||||
}
|
||||
|
||||
/// A proof failure line that names the architecture class (MF-10): the server's kernels do not load on this card.
|
||||
pub fn is_arch_failure(text: &str) -> bool {
|
||||
text.contains("named symbol not found") || text.contains("no kernel image is available")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arch_tests {
|
||||
use super::*;
|
||||
|
||||
/// The prover roll, 7 October 2026: sm_86 servers on a 4070 (8.9) and a 5090 (12.0) failed every proof; the
|
||||
/// 3080 and 3090 (8.6) proved.
|
||||
#[test]
|
||||
fn a_server_for_another_card_is_refused() {
|
||||
let sm86 = vec!["sm_86".to_string()];
|
||||
assert!(server_mismatch("8.9", &sm86).unwrap().contains("built for sm_86 and this card is sm_89"));
|
||||
assert!(server_mismatch("12.0", &sm86).unwrap().contains("sm_120"));
|
||||
assert_eq!(server_mismatch("8.6", &sm86), None);
|
||||
// a fat binary names every card
|
||||
let fat = vec!["sm_86".to_string(), "sm_89".to_string(), "sm_120".to_string()];
|
||||
assert_eq!(server_mismatch("8.9", &fat), None);
|
||||
// unknown on either side passes (the proof failure line is the second guard)
|
||||
assert_eq!(server_mismatch("", &sm86), None);
|
||||
assert_eq!(server_mismatch("8.9", &[]), None);
|
||||
assert!(is_arch_failure("CudaRustError: named symbol not found"));
|
||||
assert!(!is_arch_failure("PermissionDenied"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,22 @@ struct Tools {
|
|||
/// The node's re-derivation boundary (0.3.14, 6 October 2026): the chain block its EVM restarted at after the
|
||||
/// bodies below the pruning point were gone (`igneum_getExecStatus.restartNumber`; on a 0.3.13 node the
|
||||
/// `startedFrom` text "restart at chain block N"), else 0. The prover never claims work below it (no bodies, no
|
||||
/// The exporter's failure line. When the node's export carries the account dump (0.3.14) and the exporter's output
|
||||
/// never mentions it, the exporter at `path` predates 0.3.14: it replays the dump's own segment from the restart
|
||||
/// state and fails there with "port state root differs" (PC 1, 6 October 2026 18:16Z, block 140,662: the installer
|
||||
/// had not replaced igneum-prove-export); the line names the stale binary instead of the misleading root error.
|
||||
fn exporter_failure(export_has_dump: bool, out: &str, path: &Path, block: Option<u64>) -> String {
|
||||
let last = out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed");
|
||||
let what = match block {
|
||||
Some(b) => format!("exporter, block {b}"),
|
||||
None => "exporter".to_string(),
|
||||
};
|
||||
if export_has_dump && !out.contains("account dump") {
|
||||
return format!("{what}: stale igneum-prove-export at {}: the node's export carries the account dump and this exporter does not read it (it predates 0.3.14); install this release's exporter there ({last})", path.display());
|
||||
}
|
||||
format!("{what}: {last}")
|
||||
}
|
||||
|
||||
/// state: unprovable on every node).
|
||||
fn exec_boundary(shared: &Shared) -> u64 {
|
||||
let st = match evm_rpc(shared, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||
|
|
@ -151,23 +167,9 @@ fn exec_boundary(shared: &Shared) -> u64 {
|
|||
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
|
||||
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
||||
let url = format!("http://127.0.0.1:{}", shared.runtime.evm_port());
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "--data-binary", &format!("@{}", tmp.display())]),
|
||||
None,
|
||||
timeout + Duration::from_secs(2),
|
||||
);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
|
||||
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?;
|
||||
if let Some(err) = v.get("error") {
|
||||
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
|
||||
crate::execrpc::call(shared.runtime.evm_port(), method, params, timeout)
|
||||
}
|
||||
|
||||
fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
|
||||
|
|
@ -382,6 +384,8 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
read_ids(&shared, &t);
|
||||
}
|
||||
}
|
||||
// MF-10: the server architecture check, once per tools probe (None = not read yet; Some(None) = matches)
|
||||
let mut arch_check: Option<Option<String>> = None;
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
let enabled = shared.settings.lock().unwrap().prove;
|
||||
|
|
@ -451,6 +455,55 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// main's rule (7 October 2026, the fleet's prover roll): a 10 GB card dies at the compressed step every time
|
||||
// (p1-3080, 29 of 29, 0 paid), so with every present NVIDIA card under 12 GB the prover refuses to start,
|
||||
// Settings or not, and says why; the measured threshold moves the line (src/provedefault.rs)
|
||||
if t.cuda {
|
||||
let cards = shared.state.lock().unwrap().mining.cards.clone();
|
||||
match crate::provedefault::prove_refused_under_12gb(&cards) {
|
||||
Some(line) => {
|
||||
// settings.prove_instead (main's routing): the owner chose the prover over the miner on this card; the
|
||||
// engine holds the small cards' miners off while the prover runs and gives them back when it stops
|
||||
let instead = shared.settings.lock().unwrap().prove_instead;
|
||||
set(&shared, |p| p.under_12gb = true);
|
||||
if instead {
|
||||
shared.send(crate::engine::Cmd::ProveHold(true));
|
||||
let msg = crate::provedefault::prove_instead_line(&cards);
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = true;
|
||||
p.message = msg;
|
||||
});
|
||||
} else {
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = false;
|
||||
p.status = "off".into();
|
||||
p.message = line.into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
None => set(&shared, |p| p.under_12gb = false),
|
||||
}
|
||||
}
|
||||
// MF-10 (7 October 2026, the prover roll): a GPU server built for another card's architecture fails every
|
||||
// proof; the card's compute capability and the server's sm_ words are read once per tools probe, a mismatch
|
||||
// refuses the GPU path with the reason on the tile
|
||||
if t.cuda {
|
||||
if arch_check.is_none() {
|
||||
arch_check = Some(server_arch_check(&shared, t));
|
||||
}
|
||||
if let Some(Some(line)) = arch_check.as_ref() {
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = false;
|
||||
p.status = "off".into();
|
||||
p.message = line.clone();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = true;
|
||||
|
|
@ -462,6 +515,16 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
});
|
||||
continue;
|
||||
}
|
||||
// ledger N7 (7 October 2026, PC 1 on 0.3.18): a node before the exec RPC fix dies on igneum_getAssignedShards
|
||||
// and igneum_getProofRecords when its exec follower holds no record (rpc.rs slices records[1..=0]), and the
|
||||
// node reads synced seconds before a slow follower has one; nothing below asks until the follower reports a tip
|
||||
if !crate::update::exec_has_record(shared.runtime.evm_port()) {
|
||||
set(&shared, |p| {
|
||||
p.status = "waiting".into();
|
||||
p.message = "waiting for the node's execution layer".into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 1. the keys and the work list
|
||||
let labels = labels(&shared);
|
||||
let mut keys: Vec<(String, String)> = Vec::new();
|
||||
|
|
@ -499,6 +562,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
if paid {
|
||||
submitted.retain(|x| !(x.1 == h && x.2 == s));
|
||||
shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18));
|
||||
shared.ladder_shard_paid(n, s as u64, wei);
|
||||
set(&shared, |p| {
|
||||
p.paid += 1;
|
||||
p.paid_wei += wei;
|
||||
|
|
@ -665,7 +729,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
|
||||
let (ok, out) = run_tool(&shared, t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600), &dir.join(format!("export-{}.log", w.number)));
|
||||
if !ok || !fixture.exists() {
|
||||
return Err(format!("exporter: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, None));
|
||||
}
|
||||
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() });
|
||||
let prover_env = if t.cuda { "cuda" } else { "cpu" };
|
||||
|
|
@ -674,7 +738,10 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
let last = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
|
||||
// the root-socket class (5 October 2026, PC 2 at 20:00Z and 21:25Z): a job that ran the host as root
|
||||
// inside WSL2 left /tmp/sp1-cuda-0.sock owned by root, and this user's client cannot open it
|
||||
let hint = if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
|
||||
let hint = if crate::provedefault::is_arch_failure(&last) { " (MF-10: the proving server is built for another card's architecture; proving stays off here until a server for this card is installed)" } else if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
|
||||
if crate::provedefault::is_arch_failure(&last) {
|
||||
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, last.replace('"', "'")));
|
||||
}
|
||||
return Err(format!("prover: {last}{hint}"));
|
||||
}
|
||||
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
|
|
@ -854,7 +921,7 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
|
|||
let fixture = dir.join(format!("block-{b}.json"));
|
||||
let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log")));
|
||||
if !ok || !fixture.exists() {
|
||||
return Err(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, Some(b)));
|
||||
}
|
||||
fixtures.push(as_host_path(&fixture));
|
||||
}
|
||||
|
|
@ -1092,6 +1159,21 @@ pub fn setup(shared: &Shared) -> Result<Value, String> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_stale_exporter_is_named_when_the_export_carries_the_dump() {
|
||||
let p = Path::new("C:/x/igneum-prove-export.exe");
|
||||
let old_out = "Error: segment 140661: port state root 0xe45c differs from the node's 0xddd7; the port is not the node's executor, stop\n";
|
||||
let line = exporter_failure(true, old_out, p, Some(140662));
|
||||
assert!(line.contains("stale igneum-prove-export at C:/x/igneum-prove-export.exe"), "{line}");
|
||||
assert!(line.starts_with("exporter, block 140662:"));
|
||||
// the 0.3.14 exporter read the dump and failed later: the real line, not the stale one
|
||||
let new_out = "account dump: 79 accounts after chain block 140661, state root 0x1 (equals the node's)\nError: segment 140662: port state root 0x2 differs from the node's 0x3\n";
|
||||
let line = exporter_failure(true, new_out, p, None);
|
||||
assert_eq!(line, "exporter: Error: segment 140662: port state root 0x2 differs from the node's 0x3");
|
||||
// an export without a dump (an older node): never the stale line
|
||||
assert_eq!(exporter_failure(false, old_out, p, None), format!("exporter: {}", old_out.trim()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_ids_come_out_of_the_hosts_id_line() {
|
||||
let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n";
|
||||
|
|
@ -1132,3 +1214,23 @@ mod tests {
|
|||
assert!(probe_message(Path::new("C:\\Igneum"), false, true).contains("Ubuntu-24.04 did not answer"));
|
||||
}
|
||||
}
|
||||
|
||||
/// MF-10: the card's compute capability and the GPU server's architecture words, read through the same shell the
|
||||
/// tools live in (WSL2 on Windows). None = no mismatch (or nothing readable); Some(line) = refuse with this reason.
|
||||
fn server_arch_check(shared: &Shared, t: &Tools) -> Option<String> {
|
||||
let script = "cap=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1); srv=\"$HOME/.sp1/bin/sp1-gpu-server\"; archs=$( [ -f \"$srv\" ] && strings \"$srv\" 2>/dev/null | grep -o 'sm_[0-9]*' | sort -u | tr '\\n' ' '); echo \"CAP=$cap\"; echo \"ARCHS=$archs\"";
|
||||
let out = if t.wsl {
|
||||
let file = crate::wslhost::write_script("prove-arch", script).ok()?;
|
||||
crate::platform::quiet(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[])).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string())?
|
||||
} else {
|
||||
crate::detect::run_timeout(std::process::Command::new("bash").args(["-c", script]), None, Duration::from_secs(20))?
|
||||
};
|
||||
let cap = out.lines().find_map(|l| l.strip_prefix("CAP=")).unwrap_or("").trim().to_string();
|
||||
let archs: Vec<String> = out.lines().find_map(|l| l.strip_prefix("ARCHS=")).unwrap_or("").split_whitespace().map(|s| s.to_string()).collect();
|
||||
shared.log(&format!("prover: card compute capability {}, server architectures {}", if cap.is_empty() { "unknown" } else { &cap }, if archs.is_empty() { "unknown".to_string() } else { archs.join(" ") }));
|
||||
let line = crate::provedefault::server_mismatch(&cap, &archs);
|
||||
if let Some(l) = &line {
|
||||
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, l.replace('"', "'")));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
|
|
|||
178
app/igneum-app/src/provingdir.rs
Normal file
178
app/igneum-app/src/provingdir.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
//! The prover's working directory (`<app dir>/proving`) owns its disk (0.3.16, 6 October 2026): the fleet's segment
|
||||
//! exports (50 to 500 MB each with the old [0, last] export, never pruned) filled 60 GB on the hub and 3 to 46 GB on
|
||||
//! every standing box, and the hub's node died three times on "No space left on device" (the last at 21:42 UK).
|
||||
//! Rules: a size cap and an age cap on the directory (defaults that fit a 100 GB box for a week), enforced before
|
||||
//! every export; a segment's directory is deleted the moment its record is submitted or paid; no export starts
|
||||
//! below 10% free disk, and the skip is logged.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Defaults: 20 GB and 7 days. `IGNEUM_PROVING_DIR_MAX_GB` and `IGNEUM_PROVING_DIR_MAX_DAYS` change them.
|
||||
pub const DEFAULT_MAX_BYTES: u64 = 20 * 1024 * 1024 * 1024;
|
||||
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||
/// No export below this share of free disk.
|
||||
pub const MIN_FREE_FRACTION: f64 = 0.10;
|
||||
|
||||
pub fn max_bytes() -> u64 {
|
||||
std::env::var("IGNEUM_PROVING_DIR_MAX_GB").ok().and_then(|v| v.parse::<u64>().ok()).map(|g| g * 1024 * 1024 * 1024).unwrap_or(DEFAULT_MAX_BYTES)
|
||||
}
|
||||
|
||||
pub fn max_age() -> Duration {
|
||||
std::env::var("IGNEUM_PROVING_DIR_MAX_DAYS").ok().and_then(|v| v.parse::<u64>().ok()).map(|d| Duration::from_secs(d * 24 * 3600)).unwrap_or(DEFAULT_MAX_AGE)
|
||||
}
|
||||
|
||||
/// One entry of the directory as the planner sees it: a top-level file or a segment directory, its total bytes
|
||||
/// and its age.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub path: PathBuf,
|
||||
pub bytes: u64,
|
||||
pub age: Duration,
|
||||
}
|
||||
|
||||
/// What to delete so the directory fits: everything older than `max_age`, then the oldest entries until the total
|
||||
/// is at or under `max_bytes`. Pure, so the cap is unit-tested.
|
||||
pub fn prune_plan(entries: &[Entry], max_bytes: u64, max_age: Duration) -> Vec<PathBuf> {
|
||||
let mut keep: Vec<&Entry> = Vec::new();
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for e in entries {
|
||||
if e.age > max_age {
|
||||
out.push(e.path.clone());
|
||||
} else {
|
||||
keep.push(e);
|
||||
}
|
||||
}
|
||||
let mut total: u64 = keep.iter().map(|e| e.bytes).sum();
|
||||
// oldest first
|
||||
keep.sort_by(|a, b| b.age.cmp(&a.age));
|
||||
for e in keep {
|
||||
if total <= max_bytes {
|
||||
break;
|
||||
}
|
||||
total = total.saturating_sub(e.bytes);
|
||||
out.push(e.path.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dir_bytes(p: &Path) -> u64 {
|
||||
let mut total = 0;
|
||||
if let Ok(rd) = std::fs::read_dir(p) {
|
||||
for e in rd.flatten() {
|
||||
let m = match e.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
total += if m.is_dir() { dir_bytes(&e.path()) } else { m.len() };
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// The directory's entries, oldest by modification time.
|
||||
pub fn scan(dir: &Path) -> Vec<Entry> {
|
||||
let now = SystemTime::now();
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let Ok(m) = e.metadata() else { continue };
|
||||
let age = m.modified().ok().and_then(|t| now.duration_since(t).ok()).unwrap_or_default();
|
||||
let bytes = if m.is_dir() { dir_bytes(&e.path()) } else { m.len() };
|
||||
out.push(Entry { path: e.path(), bytes, age });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Enforces the caps on `dir`; returns (entries deleted, bytes freed).
|
||||
pub fn enforce(dir: &Path) -> (usize, u64) {
|
||||
let entries = scan(dir);
|
||||
let plan = prune_plan(&entries, max_bytes(), max_age());
|
||||
let mut freed = 0;
|
||||
for p in &plan {
|
||||
if let Some(e) = entries.iter().find(|e| &e.path == p) {
|
||||
freed += e.bytes;
|
||||
}
|
||||
let _ = if p.is_dir() { std::fs::remove_dir_all(p) } else { std::fs::remove_file(p) };
|
||||
}
|
||||
(plan.len(), freed)
|
||||
}
|
||||
|
||||
/// Free disk as a share of the volume `path` is on; None when the platform call fails.
|
||||
#[cfg(unix)]
|
||||
pub fn free_fraction(path: &Path) -> Option<f64> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
|
||||
return None;
|
||||
}
|
||||
let total = st.f_blocks as f64 * st.f_frsize as f64;
|
||||
if total <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(st.f_bavail as f64 * st.f_frsize as f64 / total)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn free_fraction(path: &Path) -> Option<f64> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn GetDiskFreeSpaceExW(dir: *const u16, avail: *mut u64, total: *mut u64, free: *mut u64) -> i32;
|
||||
}
|
||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
||||
let (mut avail, mut total, mut free) = (0u64, 0u64, 0u64);
|
||||
if unsafe { GetDiskFreeSpaceExW(wide.as_ptr(), &mut avail, &mut total, &mut free) } == 0 || total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(avail as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// The pre-export gate: the caps enforced, then the free-disk check. Err carries the line to log when the export
|
||||
/// must be skipped.
|
||||
pub fn before_export(dir: &Path) -> Result<(), String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let (n, freed) = enforce(dir);
|
||||
if n > 0 {
|
||||
eprintln!("prover: proving dir cap: {n} entries deleted, {} MB freed", freed / (1024 * 1024));
|
||||
}
|
||||
match free_fraction(dir) {
|
||||
Some(f) if f < MIN_FREE_FRACTION => Err(format!("no export: {:.1}% of the disk is free, under the {:.0}% floor; the proving dir is {} MB after its cap; free space or lower IGNEUM_PROVING_DIR_MAX_GB", f * 100.0, MIN_FREE_FRACTION * 100.0, dir_bytes(dir) / (1024 * 1024))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment's directory goes the moment its record is submitted or paid.
|
||||
pub fn remove_segment(dir: &Path, first: u64) {
|
||||
let _ = std::fs::remove_dir_all(dir.join(format!("seg-{first}")));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn e(name: &str, mb: u64, days: u64) -> Entry {
|
||||
Entry { path: PathBuf::from(name), bytes: mb * 1024 * 1024, age: Duration::from_secs(days * 24 * 3600) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cap_deletes_the_old_then_the_oldest_until_it_fits() {
|
||||
let entries = vec![e("seg-1", 400, 9), e("seg-2", 300, 3), e("seg-3", 300, 2), e("seg-4", 200, 1), e("block-5.json", 1, 0)];
|
||||
// the age cap takes seg-1; the size cap (600 MB) then takes seg-2 (oldest kept), leaving 501 MB
|
||||
let plan = prune_plan(&entries, 600 * 1024 * 1024, Duration::from_secs(7 * 24 * 3600));
|
||||
assert_eq!(plan, vec![PathBuf::from("seg-1"), PathBuf::from("seg-2")]);
|
||||
// under both caps: nothing
|
||||
assert!(prune_plan(&entries[1..], 2 * 1024 * 1024 * 1024, Duration::from_secs(30 * 24 * 3600)).is_empty());
|
||||
// a 100 GB box for a week: the default caps leave 80 GB to the node and the system
|
||||
assert_eq!(DEFAULT_MAX_BYTES, 20 * 1024 * 1024 * 1024);
|
||||
assert_eq!(DEFAULT_MAX_AGE, Duration::from_secs(7 * 24 * 3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_free_check_answers_on_this_machine() {
|
||||
let f = free_fraction(Path::new(".")).expect("statvfs");
|
||||
assert!((0.0..=1.0).contains(&f));
|
||||
}
|
||||
}
|
||||
391
app/igneum-app/src/rights.rs
Normal file
391
app/igneum-app/src/rights.rs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
//! Every right the app will ever need is taken ONCE, at install, by one elevated step; the app never prompts at runtime;
|
||||
//! an update asks again only when the list of rights grew (the project lead, 7 October 2026: "all the 'rights' need to be done on
|
||||
//! install, and then on update if anything new").
|
||||
//!
|
||||
//! The step asks only in an interactive session that is not a job's (SESSIONNAME set, no IGNEUM_JOB_* in the environment):
|
||||
//! a PC job may never need a click, so a silent install from a job logs "rights: deferred" and the next interactive start
|
||||
//! of the app asks once. The WebView2 runtime is a right too, "webview2-runtime@<min>" with <min> the host loader's minimum
|
||||
//! (app/windows/version.h IGNEUM_WEBVIEW2_MIN): the elevated step reads the Edge WebView2 client key (HKLM WOW6432Node and
|
||||
//! HKCU) and runs the bundled evergreen bootstrapper silently when the runtime is absent or below <min>; a raised minimum is
|
||||
//! a new id, so that update asks once.
|
||||
//!
|
||||
//! Windows: the installer's [Run] entry `igneum-app.exe --rights` (every install, silent ones included) compares the
|
||||
//! installed rights manifest (`<app data>/app/rights.json`: the version and the list the elevated step completed) with
|
||||
//! this build's RIGHTS; when a right is missing it writes rights.ps1 and runs it elevated once (the one UAC prompt: the
|
||||
//! Power Helper task for clock and power control, the boot task, the inbound firewall rules for the node and the pool
|
||||
//! miner), then writes the manifest; when nothing is missing it exits at once with no prompt. At runtime: Power control
|
||||
//! is a plain toggle (the Power Helper task does the work with no prompt); a right the manifest lacks is a notice
|
||||
//! ("run the installer again"), never a prompt. The prompt counts are a function here, tested with the known-failed
|
||||
//! shapes first (before 0.3.22: a fresh install then Power control on prompted; the firewall rule prompted on the first
|
||||
//! run; the boot task was registered at the engine's start).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The rights this build needs, in the order the elevated step takes them. An id never changes meaning; a new need is
|
||||
/// a new id (that is what makes an update ask once).
|
||||
pub const RIGHTS: &[(&str, &str)] = &[
|
||||
("power-helper-task", "the Igneum Power Helper task: the clock and power limits of NVIDIA cards with no prompt (src/powertask.rs)"),
|
||||
("boot-task", "the Igneum Miner (boot) task: the engine starts at boot with nobody logged on (src/boot.rs)"),
|
||||
("firewall-node", "the inbound firewall rule for igneumd.exe (other nodes can dial in)"),
|
||||
("firewall-miner", "the inbound firewall rule for igneum-miner.exe (a pool's stratum port)"),
|
||||
(WEBVIEW2_RIGHT, "the WebView2 runtime the window needs, at or above the host loader's minimum (installed silently from the bundled evergreen bootstrapper when absent or older)"),
|
||||
// the driver lane's right (branch driver-hold-22, src/driverinstall.rs): the same Power Helper task with a two-hour run limit, so a
|
||||
// vendor's driver installs unattended through it; at the merge the Power Helper line of script() becomes driverinstall::register_script
|
||||
("driver-install-task", "the Igneum Power Helper task with a two-hour run limit: a vendor's driver installs unattended through it, the vendor's cards held, no prompt (src/driverinstall.rs)"),
|
||||
];
|
||||
|
||||
/// The host loader's minimum WebView2 runtime (app/windows/version.h IGNEUM_WEBVIEW2_MIN), read at build time so the two
|
||||
/// never drift; the right's id carries it.
|
||||
pub const WEBVIEW2_MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h"));
|
||||
pub const WEBVIEW2_RIGHT: &str = const_format_webview2_right();
|
||||
/// The bundled bootstrapper in the install folder and its pinned sha file (packaging/windows/webview2.sha256).
|
||||
pub const WEBVIEW2_BOOTSTRAPPER: &str = "MicrosoftEdgeWebview2Setup.exe";
|
||||
pub const WEBVIEW2_KEY: &str = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
|
||||
|
||||
const fn webview2_min_from_header(h: &str) -> &str {
|
||||
// the line `#define IGNEUM_WEBVIEW2_MIN "a.b.c.d"`: the text between the quotes
|
||||
let b = h.as_bytes();
|
||||
let key = b"IGNEUM_WEBVIEW2_MIN \"";
|
||||
let mut i = 0;
|
||||
while i + key.len() < b.len() {
|
||||
let mut j = 0;
|
||||
while j < key.len() && b[i + j] == key[j] {
|
||||
j += 1;
|
||||
}
|
||||
if j == key.len() {
|
||||
let start = i + key.len();
|
||||
let mut end = start;
|
||||
while end < b.len() && b[end] != b'"' {
|
||||
end += 1;
|
||||
}
|
||||
// SAFETY of the slice: start and end sit on ASCII bytes of a str literal
|
||||
match h.split_at(start).1.split_at(end - start).0 {
|
||||
s => return s,
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
"0"
|
||||
}
|
||||
const fn const_format_webview2_right() -> &'static str {
|
||||
// "webview2-runtime@" + WEBVIEW2_MIN, built once at compile time
|
||||
const PREFIX: &str = "webview2-runtime@";
|
||||
const MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h"));
|
||||
const LEN: usize = PREFIX.len() + MIN.len();
|
||||
const BUF: [u8; LEN] = {
|
||||
let mut out = [0u8; LEN];
|
||||
let p = PREFIX.as_bytes();
|
||||
let m = MIN.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < p.len() {
|
||||
out[i] = p[i];
|
||||
i += 1;
|
||||
}
|
||||
let mut j = 0;
|
||||
while j < m.len() {
|
||||
out[p.len() + j] = m[j];
|
||||
j += 1;
|
||||
}
|
||||
out
|
||||
};
|
||||
match std::str::from_utf8(&BUF) {
|
||||
Ok(s) => s,
|
||||
Err(_) => "webview2-runtime@0",
|
||||
}
|
||||
}
|
||||
|
||||
pub const MANIFEST_FILE: &str = "rights.json";
|
||||
pub const SCRIPT_FILE: &str = "rights.ps1";
|
||||
|
||||
/// The manifest the elevated step leaves: which rights hold, from which version, when.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Manifest {
|
||||
pub version: String,
|
||||
pub rights: Vec<String>,
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(text: &str) -> Option<Manifest> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
Some(Manifest {
|
||||
version: v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(),
|
||||
rights: v.get("rights").and_then(|x| x.as_array()).map(|a| a.iter().filter_map(|r| r.as_str().map(|s| s.to_string())).collect()).unwrap_or_default(),
|
||||
at: v.get("at").and_then(|x| x.as_u64()).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::json!({ "version": self.version, "rights": self.rights, "at": self.at, "format": "igneum-rights-1" }).to_string()
|
||||
}
|
||||
pub fn load(app_dir: &Path) -> Option<Manifest> {
|
||||
std::fs::read_to_string(app_dir.join(MANIFEST_FILE)).ok().and_then(|t| Manifest::parse(&t))
|
||||
}
|
||||
pub fn save(&self, app_dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::write(app_dir.join(MANIFEST_FILE), self.to_json())
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids this build wants that the installed manifest does not hold (every id when there is no manifest).
|
||||
pub fn missing(installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> Vec<String> {
|
||||
let have: Vec<&str> = installed.map(|m| m.rights.iter().map(|s| s.as_str()).collect()).unwrap_or_default();
|
||||
wanted.iter().map(|(id, _)| *id).filter(|id| !have.contains(id)).map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Where the step runs: an interactive session that is not a job's may ask; anything else defers (the project lead, 7 October 2026:
|
||||
/// no PC job may need a click).
|
||||
pub fn may_ask(session_name: Option<&str>, job_env: bool) -> bool {
|
||||
crate::boot::interactive_session(session_name) && !job_env
|
||||
}
|
||||
|
||||
/// Is this process inside a remote job (the runner's IGNEUM_JOB_* environment)?
|
||||
pub fn in_job_env() -> bool {
|
||||
std::env::vars().any(|(k, _)| k.starts_with("IGNEUM_JOB_"))
|
||||
}
|
||||
|
||||
/// The outcome of the install step.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Step {
|
||||
/// nothing missing: no prompt
|
||||
Nothing,
|
||||
/// a right is missing and this session may ask: one prompt
|
||||
Asked,
|
||||
/// a right is missing but this is a job's or a session-less run: no prompt, the next interactive start asks
|
||||
Deferred,
|
||||
}
|
||||
|
||||
pub fn step(installed: Option<&Manifest>, wanted: &[(&str, &str)], session_name: Option<&str>, job_env: bool) -> Step {
|
||||
if missing(installed, wanted).is_empty() {
|
||||
Step::Nothing
|
||||
} else if may_ask(session_name, job_env) {
|
||||
Step::Asked
|
||||
} else {
|
||||
Step::Deferred
|
||||
}
|
||||
}
|
||||
|
||||
/// The WebView2 version a registry read gave against the minimum: true when the runtime must be installed.
|
||||
pub fn webview2_needs_install(installed: Option<&str>, min: &str) -> bool {
|
||||
let parse = |v: &str| -> Vec<u64> { v.trim().split('.').map(|p| p.trim().parse::<u64>().unwrap_or(0)).collect() };
|
||||
match installed.map(|v| v.trim()).filter(|v| !v.is_empty()) {
|
||||
None => true,
|
||||
Some(v) => parse(v) < parse(min),
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens to the user.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
/// the installer's --rights step (a fresh install or an update)
|
||||
Install,
|
||||
/// Power control switched on in Settings
|
||||
PowerControlOn,
|
||||
/// the engine's first run (the firewall rule, before 0.3.22)
|
||||
FirstRun,
|
||||
}
|
||||
|
||||
/// 0.3.22: how many administrator prompts an event raises. Only the install step asks, and only when a right is missing.
|
||||
pub fn prompts(event: Event, installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> u32 {
|
||||
match event {
|
||||
Event::Install => if missing(installed, wanted).is_empty() { 0 } else { 1 },
|
||||
Event::PowerControlOn | Event::FirstRun => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Before 0.3.22, for the record: the installer asked nothing; the first run asked for the firewall rule; Power control on
|
||||
/// asked when the Power Helper task was not registered yet.
|
||||
pub fn legacy_prompts(event: Event, power_task_registered: bool) -> u32 {
|
||||
match event {
|
||||
Event::Install => 0,
|
||||
Event::FirstRun => 1,
|
||||
Event::PowerControlOn => if power_task_registered { 0 } else { 1 },
|
||||
}
|
||||
}
|
||||
|
||||
fn ps_quote(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// The one elevated script: every right in RIGHTS, idempotent (a rule is removed before it is added, a task is
|
||||
/// registered with -Force). `exe` is the installed igneum-app.exe, `install_dir` its folder, `data_root` the user's
|
||||
/// data root for the boot task.
|
||||
pub fn script(exe: &Path, install_dir: &Path, data_root: &Path) -> String {
|
||||
let node = ps_quote(&install_dir.join("igneumd.exe").display().to_string());
|
||||
let miner = ps_quote(&install_dir.join("igneum-miner.exe").display().to_string());
|
||||
let mut s = String::from("$ErrorActionPreference = 'Continue'\r\n# Igneum rights, one elevated step (src/rights.rs). Not for running by hand.\r\n");
|
||||
s.push_str(&crate::powertask::register_script(exe).replace("exit 0\r\n", ""));
|
||||
s.push_str(&crate::boot::register_script(exe, data_root).replace("exit 0\r\n", ""));
|
||||
for (name, prog) in [("Igneum Miner node", node), ("Igneum Miner pool", miner)] {
|
||||
s.push_str(&format!(
|
||||
"& netsh.exe advfirewall firewall delete rule name='{name}' | Out-Null\r\n\
|
||||
& netsh.exe advfirewall firewall add rule name='{name}' dir=in action=allow enable=yes profile=private,domain protocol=TCP program='{prog}' | Out-Null\r\n"
|
||||
));
|
||||
}
|
||||
s.push_str(&webview2_script(install_dir));
|
||||
s.push_str("exit 0\r\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The WebView2 part of the elevated script: the client key's pv (HKLM WOW6432Node, then HKCU), the bundled bootstrapper
|
||||
/// /silent /install when absent or below the minimum (exit 0 required), the version read back, one log line either way.
|
||||
pub fn webview2_script(install_dir: &Path) -> String {
|
||||
let boot = ps_quote(&install_dir.join(WEBVIEW2_BOOTSTRAPPER).display().to_string());
|
||||
let log = ps_quote(&install_dir.join("rights.log").display().to_string());
|
||||
format!(
|
||||
"function WV2 {{ foreach ($k in @('HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{key}', 'HKCU:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{key}')) {{ $v = (Get-ItemProperty $k -ErrorAction SilentlyContinue).pv; if ($v) {{ return \"$v\" }} }}; return '' }}\r\n\
|
||||
$wvMin = '{min}'\r\n\
|
||||
$wvHave = WV2\r\n\
|
||||
$wvNeed = (-not $wvHave) -or ([version]$wvHave -lt [version]$wvMin)\r\n\
|
||||
if ($wvNeed) {{\r\n\
|
||||
\x20 if (Test-Path '{boot}') {{ $wp = Start-Process -FilePath '{boot}' -ArgumentList @('/silent', '/install') -Wait -PassThru -WindowStyle Hidden; Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', installed from the bundled bootstrapper, exit ' + $wp.ExitCode + ', now ' + (WV2)) }}\r\n\
|
||||
\x20 else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', below ' + $wvMin + ', and no bootstrapper beside the app; the window opens the dashboard in the browser until the runtime is installed') }}\r\n\
|
||||
}} else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: ' + $wvHave + ' ok (minimum ' + $wvMin + ')') }}\r\n",
|
||||
key = WEBVIEW2_KEY,
|
||||
min = WEBVIEW2_MIN
|
||||
)
|
||||
}
|
||||
|
||||
/// The installer's step. Compares, asks once when something is missing, writes the manifest. Ok(prompted).
|
||||
pub fn install(exe: &Path, install_dir: &Path, data_root: &Path, app_dir: &Path, version: &str) -> Result<bool, String> {
|
||||
let installed = Manifest::load(app_dir);
|
||||
match step(installed.as_ref(), RIGHTS, std::env::var("SESSIONNAME").ok().as_deref(), in_job_env()) {
|
||||
Step::Nothing => return Ok(false),
|
||||
Step::Deferred => {
|
||||
// a job's or a session-less install never raises a prompt (the project lead, 7 October 2026); the next interactive start asks once
|
||||
return Err(format!("rights: deferred ({} missing: {}); no prompt in a job's or a session-less install, the next interactive start of the app asks once", missing(installed.as_ref(), RIGHTS).len(), missing(installed.as_ref(), RIGHTS).join(", ")));
|
||||
}
|
||||
Step::Asked => {}
|
||||
}
|
||||
let _ = std::fs::create_dir_all(app_dir);
|
||||
let path: PathBuf = app_dir.join(SCRIPT_FILE);
|
||||
std::fs::write(&path, [b"\xEF\xBB\xBF".as_slice(), script(exe, install_dir, data_root).as_bytes()].concat()).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let line = format!("\"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", crate::platform::tool("powershell").display(), path.display());
|
||||
crate::platform::run_elevated(&line)?;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
return Err("the rights step is Windows only".into());
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
let m = Manifest { version: version.to_string(), rights: RIGHTS.iter().map(|(id, _)| id.to_string()).collect(), at: crate::platform::unix_now() };
|
||||
m.save(app_dir).map_err(|e| format!("cannot write the rights manifest: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the installed manifest hold this right? (The runtime's question before it would have prompted.)
|
||||
pub fn held(app_dir: &Path, id: &str) -> bool {
|
||||
Manifest::load(app_dir).map(|m| m.rights.iter().any(|r| r == id)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The sentence the dashboard shows for a right the manifest lacks.
|
||||
pub fn missing_note(id: &str) -> String {
|
||||
let what = RIGHTS.iter().find(|(i, _)| *i == id).map(|(_, d)| *d).unwrap_or(id);
|
||||
format!("not set up on this PC ({what}); run the Igneum Miner installer again: it asks for administrator rights once and sets everything up")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn m(rights: &[&str]) -> Manifest {
|
||||
Manifest { version: "0.3.22".into(), rights: rights.iter().map(|s| s.to_string()).collect(), at: 1 }
|
||||
}
|
||||
|
||||
/// Known-failed first: before 0.3.22 a fresh install asked nothing and then the first run and Power control on each
|
||||
/// asked (two prompts on the way to a tuned card); 0.3.22 asks once at install and never again.
|
||||
#[test]
|
||||
fn a_fresh_install_then_power_control_on_shows_one_prompt_at_install_and_none_after() {
|
||||
assert_eq!(legacy_prompts(Event::Install, false) + legacy_prompts(Event::FirstRun, false) + legacy_prompts(Event::PowerControlOn, false), 2, "the old way: two prompts");
|
||||
assert_eq!(prompts(Event::Install, None, RIGHTS), 1, "the one prompt, at install");
|
||||
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
|
||||
assert_eq!(prompts(Event::FirstRun, Some(&installed), RIGHTS), 0);
|
||||
assert_eq!(prompts(Event::PowerControlOn, Some(&installed), RIGHTS), 0);
|
||||
}
|
||||
|
||||
/// the project lead's rule of the same night: no PC job may need a click. Known-failed first: 3fbf4280's step asked on every install with
|
||||
/// a missing right, a job's silent install included.
|
||||
#[test]
|
||||
fn the_rights_step_asks_only_in_an_interactive_session_that_is_not_a_jobs() {
|
||||
assert_eq!(step(None, RIGHTS, Some("Console"), true), Step::Deferred, "a job's install (IGNEUM_JOB_* set): never a prompt");
|
||||
assert_eq!(step(None, RIGHTS, None, false), Step::Deferred, "no interactive session: never a prompt");
|
||||
assert_eq!(step(None, RIGHTS, Some("Console"), false), Step::Asked, "the person at the PC: one prompt");
|
||||
let all = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
|
||||
assert_eq!(step(Some(&all), RIGHTS, Some("Console"), false), Step::Nothing);
|
||||
assert_eq!(step(Some(&all), RIGHTS, None, true), Step::Nothing, "nothing missing: nothing, wherever it runs");
|
||||
assert!(may_ask(Some("RDP-Tcp#2"), false) && !may_ask(Some("Console"), true) && !may_ask(None, false));
|
||||
}
|
||||
|
||||
/// The WebView2 runtime as a right (packaging's step, named 7 October 2026). Known-failed first: before it the runtime
|
||||
/// missing meant the window said "install the runtime from microsoft.com" and nothing installed it.
|
||||
#[test]
|
||||
fn the_webview2_runtime_is_a_right_with_the_minimum_in_its_id() {
|
||||
assert_eq!(WEBVIEW2_MIN, "109.0.1518.78", "read from app/windows/version.h at build time");
|
||||
assert_eq!(WEBVIEW2_RIGHT, "webview2-runtime@109.0.1518.78");
|
||||
assert!(RIGHTS.iter().any(|(id, _)| *id == WEBVIEW2_RIGHT));
|
||||
// absent runtime: one prompt at install; present at or above the minimum: the right still has to be taken once
|
||||
// (the manifest records it), but the script installs nothing (its own check); a raised minimum is a new id
|
||||
let old = m(&["power-helper-task", "boot-task", "firewall-node", "firewall-miner", "webview2-runtime@100.0.0.0", "driver-install-task"]);
|
||||
assert_eq!(missing(Some(&old), RIGHTS), vec![WEBVIEW2_RIGHT.to_string()], "a raised minimum is exactly one new right");
|
||||
assert_eq!(prompts(Event::Install, Some(&old), RIGHTS), 1);
|
||||
let now = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
|
||||
assert_eq!(prompts(Event::Install, Some(&now), RIGHTS), 0, "the same minimum: no prompt");
|
||||
assert!(webview2_needs_install(None, WEBVIEW2_MIN), "absent: install");
|
||||
assert!(webview2_needs_install(Some(""), WEBVIEW2_MIN));
|
||||
assert!(webview2_needs_install(Some("108.0.1462.76"), WEBVIEW2_MIN), "below: install");
|
||||
assert!(!webview2_needs_install(Some("109.0.1518.78"), WEBVIEW2_MIN), "equal: nothing");
|
||||
assert!(!webview2_needs_install(Some("154.0.4258.62"), WEBVIEW2_MIN), "PC 2's runtime: nothing");
|
||||
let s = webview2_script(Path::new("C:\\p\\Igneum Miner"));
|
||||
assert!(s.contains("EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}") && s.contains("HKCU:"), "both registry forms");
|
||||
assert!(s.contains("MicrosoftEdgeWebview2Setup.exe") && s.contains("'/silent', '/install'") && s.contains("-Wait -PassThru -WindowStyle Hidden"));
|
||||
assert!(s.contains("$wvMin = '109.0.1518.78'") && s.contains("[version]$wvHave -lt [version]$wvMin"));
|
||||
assert!(s.contains("webview2: ") && s.contains(" ok (minimum "), "one log line either way");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_with_no_new_right_shows_no_prompt() {
|
||||
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
|
||||
assert_eq!(missing(Some(&installed), RIGHTS), Vec::<String>::new());
|
||||
assert_eq!(prompts(Event::Install, Some(&installed), RIGHTS), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_with_a_new_right_shows_exactly_one_prompt() {
|
||||
let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
|
||||
let grown: Vec<(&str, &str)> = RIGHTS.iter().cloned().chain([("example-new-right", "a right a later build needs")]).collect();
|
||||
assert_eq!(missing(Some(&installed), &grown), vec!["example-new-right".to_string()]);
|
||||
assert_eq!(prompts(Event::Install, Some(&installed), &grown), 1);
|
||||
// and a manifest from an older build that lacks two rights still asks exactly once
|
||||
let older = m(&["power-helper-task"]);
|
||||
assert_eq!(missing(Some(&older), RIGHTS).len(), RIGHTS.len() - 1);
|
||||
assert_eq!(prompts(Event::Install, Some(&older), RIGHTS), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_manifest_round_trips_and_a_bad_file_reads_as_none() {
|
||||
let a = m(&["power-helper-task", "boot-task"]);
|
||||
assert_eq!(Manifest::parse(&a.to_json()), Some(a.clone()));
|
||||
assert!(a.to_json().contains("\"format\":\"igneum-rights-1\""));
|
||||
assert_eq!(Manifest::parse("not json"), None);
|
||||
assert_eq!(Manifest::parse("{}"), Some(Manifest::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_one_script_takes_every_right_and_is_idempotent() {
|
||||
let s = script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\p\\Igneum Miner"), Path::new("C:\\u\\igneum"));
|
||||
assert!(s.contains("-TaskName 'Igneum Power Helper'"), "the Power Helper task");
|
||||
assert!(s.contains("-TaskName 'Igneum Miner (boot)'"), "the boot task");
|
||||
// the join's separator is the test host's (the box runs this on Linux), so the path is read by its file name
|
||||
assert!(s.contains("advfirewall firewall add rule name='Igneum Miner node'") && s.contains("Igneum Miner") && s.contains("igneumd.exe'"), "{s}");
|
||||
assert!(s.contains("advfirewall firewall add rule name='Igneum Miner pool'") && s.contains("igneum-miner.exe'"));
|
||||
assert!(s.contains("firewall delete rule name='Igneum Miner node'"), "the rule is replaced, never doubled");
|
||||
assert_eq!(s.matches("exit 0").count(), 1, "one exit at the end, the sub-scripts' own stripped");
|
||||
assert!(!s.contains("RunAs"), "the script itself never elevates: the step that runs it does, once (the bootstrapper's hidden run is a plain run)"); // console: a test string, not a spawn
|
||||
for (id, _) in RIGHTS {
|
||||
assert!(!id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '@' || c == '.'), "ids are stable lowercase words, with an @version suffix where the minimum is part of the right: {id}");
|
||||
}
|
||||
assert!(missing_note("firewall-node").contains("run the Igneum Miner installer again"));
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ 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 MARK: &str = include_str!("../ui/mark.svg");
|
||||
const DAG_JS: &str = include_str!("../ui/live-dag.js");
|
||||
/// the renderer pack's proof visual (EMBER 02, the site lane's byte-identical copy; miner-ui-5)
|
||||
const PROOF_JS: &str = include_str!("../ui/proof-core.js");
|
||||
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
|
||||
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
|
||||
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
|
||||
|
|
@ -138,6 +141,15 @@ fn json_resp(stream: &mut TcpStream, status: u16, v: Value) {
|
|||
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
|
||||
}
|
||||
|
||||
/// Where a screenshot lands: `<data root>/shots/igneum-<name>-<unix>.png`, the name reduced to [a-z0-9-] (at most
|
||||
/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder.
|
||||
pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf {
|
||||
let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect();
|
||||
let safe = if safe.is_empty() { "shot".to_string() } else { safe };
|
||||
data_root.join("shots").join(format!("igneum-{safe}-{unix}.png"))
|
||||
}
|
||||
|
||||
|
||||
fn query_param(q: &str, key: &str) -> Option<String> {
|
||||
q.split('&').find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
|
|
@ -160,11 +172,31 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
return;
|
||||
};
|
||||
let rest = if rest.is_empty() { "/" } else { rest };
|
||||
// ui-ota (src/uiota.rs): an active interface bundle serves its files in place of the embedded ones; the names are
|
||||
// fixed (uiota::SERVED), nothing else is read from the folder; a file the bundle lacks falls back to the embedded one
|
||||
if req.method == "GET" {
|
||||
let rel = if rest == "/" { "index.html" } else { rest.trim_start_matches('/') };
|
||||
if crate::uiota::SERVED.contains(&rel) {
|
||||
if let Some(dir) = shared.ui_dir() {
|
||||
if let Ok(bytes) = std::fs::read(dir.join(rel)) {
|
||||
if rel == "index.html" {
|
||||
let v = std::fs::read_to_string(dir.join("VERSION")).unwrap_or_default().trim().to_string();
|
||||
shared.send(Cmd::UiPageLoaded(v));
|
||||
}
|
||||
respond(&mut stream, 200, crate::uiota::content_type(rel), &bytes, rel.starts_with("fonts/"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match (req.method.as_str(), rest) {
|
||||
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
|
||||
("GET", "/VERSION") => respond(&mut stream, 200, "text/plain; charset=utf-8", crate::uiota::EMBEDDED_VERSION.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", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
|
||||
("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false),
|
||||
("GET", "/proof-core.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", PROOF_JS.as_bytes(), false),
|
||||
("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),
|
||||
|
|
@ -178,6 +210,20 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
let v = shared.state_json();
|
||||
json_resp(&mut stream, 200, v);
|
||||
}
|
||||
// the chain scene's feed (src/live.rs): the observer's /api/live with this machine's lane as "you", cached 2 s
|
||||
("GET", "/api/live") => {
|
||||
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
|
||||
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
|
||||
let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
|
||||
json_resp(&mut stream, 200, crate::live::fetch(&shared, &live_api, window, &ids));
|
||||
}
|
||||
// miner-ui-5: the ladder's chain facts (src/chainfacts.rs): the node's getFinalityWeights through the local
|
||||
// node when it answers igneum_getFinalityWeights, else the observer's relay; cached 10 s
|
||||
("GET", "/api/ladder") => {
|
||||
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
|
||||
let ids: Vec<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
|
||||
json_resp(&mut stream, 200, crate::chainfacts::fetch(shared.runtime.evm_port(), &live_api, &ids));
|
||||
}
|
||||
("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);
|
||||
|
|
@ -256,9 +302,57 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
|
||||
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
|
||||
let climb = body.get("climb").and_then(|v| v.as_bool());
|
||||
// with "key": that card's goal override ("" or "global" clears it); without: the global goal
|
||||
let key = body.get("key").and_then(|v| v.as_str()).map(|k| k.to_string());
|
||||
if let Some(k) = key {
|
||||
let g = body.get("goal").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let g = if g.is_empty() || g == "global" { String::new() } else { crate::ember::Goal::parse(g).name().to_string() };
|
||||
shared.send(Cmd::TuneCardGoal(k, g));
|
||||
return Ok(json!({ "ok": true }));
|
||||
}
|
||||
shared.send(Cmd::TuneGoal(goal, price, climb));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/network" => {
|
||||
// the network step: {network: "devnet-3" | "testnet-1", confirm: bool}; the engine applies config::network_switch
|
||||
let want = body.get("network").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let confirm = body.get("confirm").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
shared.send(Cmd::Network(want, confirm));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/region" => {
|
||||
// Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched)
|
||||
let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string());
|
||||
// hundredths of the chosen unit per kWh: up to 100,000 so a zero-decimal currency (yen, won) fits
|
||||
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=100_000.0).contains(p));
|
||||
let currency = body.get("currency").and_then(|v| v.as_str()).map(|c| c.to_string());
|
||||
shared.send(Cmd::Region(region, price, currency));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/heat" => {
|
||||
// Ember Heat: the switch, the set point, the schedule with the window's clock offset, a typed room reading
|
||||
let patch = crate::engine::HeatPatch {
|
||||
on: body.get("on").and_then(|v| v.as_bool()),
|
||||
set_c: body.get("set_c").and_then(|v| v.as_f64()),
|
||||
schedule: body.get("schedule").and_then(|v| v.as_str()).map(|t| (t.to_string(), body.get("tz_min").and_then(|v| v.as_i64()).unwrap_or(0).clamp(-840, 840) as i32)),
|
||||
room_c: body.get("room_c").and_then(|v| v.as_f64()),
|
||||
};
|
||||
if let Some(c) = patch.set_c {
|
||||
if !(crate::heat::SET_MIN_C..=crate::heat::SET_MAX_C).contains(&c) {
|
||||
return Err(format!("the set point is {:.0} to {:.0} degrees", crate::heat::SET_MIN_C, crate::heat::SET_MAX_C));
|
||||
}
|
||||
}
|
||||
if let Some((t, _)) = &patch.schedule {
|
||||
crate::heat::parse_schedule(t)?;
|
||||
}
|
||||
if let Some(c) = patch.room_c {
|
||||
if !(-20.0..=50.0).contains(&c) {
|
||||
return Err("a room reading is -20 to 50 degrees".into());
|
||||
}
|
||||
}
|
||||
shared.send(Cmd::Heat(patch));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/settings" => {
|
||||
let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32);
|
||||
let vote = body.get("vote").and_then(|v| v.as_bool());
|
||||
|
|
@ -267,10 +361,24 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
let display_name = s("display_name");
|
||||
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
|
||||
let proof_verify_trust = body.get("proof_verify_trust").and_then(|v| v.as_bool());
|
||||
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust)
|
||||
let profile_public = body.get("profile_public").and_then(|v| v.as_bool());
|
||||
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust, profile_public)
|
||||
}
|
||||
"/api/prove" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
|
||||
"/api/prove/instead" => shared.set_prove_instead(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
|
||||
"/api/prove/setup" => crate::prover::setup(shared),
|
||||
"/api/drivers/install" => {
|
||||
let vendor = body.get("vendor").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !["nvidia", "amd", "intel"].contains(&vendor.as_str()) {
|
||||
return Err("vendor must be nvidia, amd or intel".into());
|
||||
}
|
||||
shared.send(Cmd::DriverInstall(vendor));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/drivers/restart" => {
|
||||
shared.send(Cmd::DriverRestart);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/check" => {
|
||||
shared.send(Cmd::CheckUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
|
|
@ -279,6 +387,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::InstallUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/ui/health" => {
|
||||
// ui-ota: the page's first-paint ping (no error) or the error it caught before it (src/uiota.rs)
|
||||
let err = body.get("error").and_then(|v| v.as_str()).filter(|e| !e.is_empty()).map(|e| e.to_string());
|
||||
shared.send(Cmd::UiHealth(err));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/ui/builtin" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::UiBuiltin(on));
|
||||
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));
|
||||
|
|
@ -351,6 +470,38 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::ClockCheck);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
|
||||
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
|
||||
// what it shows to that path under <data root>/shots/, where a collect job's glob "shots/*.png" picks it up.
|
||||
// Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file.
|
||||
// miner-ui-5: the block card's PNG, drawn by the page on a canvas (no network call), written under
|
||||
// <data root>/cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}.
|
||||
// first-block-21: the page showed (or the user dismissed) the block card for the milestone at {count, at};
|
||||
// the card is spent for later runs and a first-block card sets the lifetime flag (src/ladder.rs card_seen)
|
||||
"/api/card/seen" => {
|
||||
let count = body.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let at = body.get("at").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let changed = shared.ladder.lock().unwrap().card_seen(count, at);
|
||||
if changed { shared.save_ladder(); }
|
||||
Ok(json!({ "ok": true, "changed": changed }))
|
||||
}
|
||||
"/api/card" => {
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block");
|
||||
let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?;
|
||||
let bytes = crate::card::decode_data_url(png).ok_or("the png is not a base64 data URL")?;
|
||||
let path = crate::card::card_path(&crate::platform::data_root(), name, crate::platform::unix_now());
|
||||
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
|
||||
std::fs::write(&path, &bytes).map_err(|e| format!("could not write the card: {e}"))?;
|
||||
if body.get("reveal").and_then(|v| v.as_bool()).unwrap_or(true) { crate::platform::reveal_file(&path); }
|
||||
Ok(json!({ "ok": true, "path": path.display().to_string(), "bytes": bytes.len() }))
|
||||
}
|
||||
"/api/shot" => {
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now());
|
||||
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
|
||||
println!("SHOT {}", path.display());
|
||||
Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" }))
|
||||
}
|
||||
"/api/quit" => {
|
||||
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
|
||||
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
|
||||
|
|
@ -359,3 +510,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
_ => Err("unknown api".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_shot_lands_under_the_data_root_with_a_safe_name() {
|
||||
let root = std::path::Path::new("/tmp/igneum-data");
|
||||
assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png"));
|
||||
assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png"));
|
||||
assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png"));
|
||||
assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png"));
|
||||
assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,29 @@ pub struct NodeState {
|
|||
pub version: String,
|
||||
pub last_reading_age_s: f64,
|
||||
pub message: String,
|
||||
/// N4 (6 October 2026, the frozen tip): seconds since the sink block's header time, from the watch line's
|
||||
/// tip_age_s (the node lane, ca3-v4-0316); -1 until the node reports it. "synced" needs it at or under 120.
|
||||
pub tip_age_s: f64,
|
||||
/// whose node this is (publish 2's read-back, 6 October 2026): "app" (started by this app), "external" (another
|
||||
/// node on this machine holds the ports; used after a check), "none" (the ports are taken by a node on other rules
|
||||
/// or another network, so no node is read), "" before the first start
|
||||
pub source: String,
|
||||
/// for an external node: match | mismatch | unknown (an older node that cannot answer the check)
|
||||
pub rules_check: String,
|
||||
/// the one line that says what happened at the ports, kept for the Node details
|
||||
pub port_note: String,
|
||||
/// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), ""
|
||||
pub digest_source: String,
|
||||
/// which node this app reads: "own" (it started one), "external" (another node on this machine holds the ports),
|
||||
/// "none" (the ports are taken by a node it refuses), "" before the first decision. Re-decided when the other
|
||||
/// node goes away (7 October 2026): after 60 s the app starts its own on the freed ports.
|
||||
pub mode: String,
|
||||
/// why the mode is what it is, one line
|
||||
pub mode_reason: String,
|
||||
/// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen"
|
||||
pub sync_cause: String,
|
||||
/// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node
|
||||
pub stall_exits: u32,
|
||||
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
|
||||
pub consensus_switch_daa: u64,
|
||||
pub override_restart_wait: String,
|
||||
|
|
@ -54,7 +77,7 @@ pub struct CardState {
|
|||
pub detail: String, // memory, cores
|
||||
pub device: String, // the worker's --device value (Windows)
|
||||
pub enabled: bool,
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed (no "faulted": no fault is permanent, 7 October 2026) | unusable (the OS reports a problem) | removed (unplugged)
|
||||
pub hash_now: f64, // MH/s, the last interval
|
||||
pub hash_avg: f64, // MH/s since the start
|
||||
pub accepted: u64,
|
||||
|
|
@ -121,11 +144,19 @@ pub struct CardState {
|
|||
pub mem_cap_mhz: u32, // Ember 2: the memory clock set by the tune (0 = the driver's default)
|
||||
pub amd_ordinal: i64, // the `amd N` ordinal of igneum-gpu-telemetry (-1 = unknown)
|
||||
pub driver: String, // the driver version (nvidia-smi, or the worker's race line)
|
||||
/// driver-check (7 October 2026): the comparable version the OS reports (nvidia-smi's for NVIDIA, Windows'
|
||||
/// DriverVersion for AMD and Intel; empty = none found) and what the row says about it against the manifest's table
|
||||
pub driver_os: String,
|
||||
pub driver_offer: Option<crate::drivers::Offer>,
|
||||
pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown
|
||||
pub tune_control: bool, // both knobs reach the card (else measure only; sweep_note says why)
|
||||
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
|
||||
pub tune_source: String, // full | confirm | baseline
|
||||
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
|
||||
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
|
||||
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
|
||||
pub tune_before_mhs: f64,
|
||||
pub tune_floor: bool, // the chosen clock is the ladder's floor (the row's sub-line says so)
|
||||
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
|
||||
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
|
||||
pub tune_step: u32,
|
||||
|
|
@ -163,6 +194,10 @@ pub struct MiningState {
|
|||
/// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime
|
||||
pub fee_session: u64,
|
||||
pub fee_total: u64,
|
||||
/// Miner UI 4 (6 October 2026): the sum of the mining cards' draw (a reading under 60 s old), and that draw as
|
||||
/// £ a day at settings.power_price_pence (0 when no price is set)
|
||||
pub watts_total: f64,
|
||||
pub pounds_per_day: f64,
|
||||
}
|
||||
|
||||
/// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`).
|
||||
|
|
@ -188,6 +223,8 @@ pub struct ProgramState {
|
|||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct ProvingState {
|
||||
pub enabled: bool,
|
||||
/// every present NVIDIA card is under 12 GB: the prover refuses unless settings.prove_instead holds the miner off
|
||||
pub under_12gb: bool,
|
||||
/// the host runs here (macOS, Linux) or inside WSL2 (Windows); false = Set up needed
|
||||
pub available: bool,
|
||||
pub setup_hint: String,
|
||||
|
|
@ -249,6 +286,23 @@ pub struct FinalityState {
|
|||
pub age_s: f64,
|
||||
pub votes: u64,
|
||||
pub message: String,
|
||||
/// Horizon polish Q83/Q84 (6 October 2026): the network's finality is paused (a synced node, no checkpoint lock
|
||||
/// for manifest::FINALITY_PAUSE_S); since when (the last lock's time, else the engine's start); the cause when
|
||||
/// the node carries it (its `finality_reason=` / `held_by=` line), else the plain two-thirds line; and the one
|
||||
/// sentence every surface shows: "Finality paused since 18:39 UTC: under two thirds of the weight is signing"
|
||||
pub paused: bool,
|
||||
pub paused_since: f64,
|
||||
pub reason: String,
|
||||
pub held_by: String,
|
||||
pub line: String,
|
||||
/// the node's word (igneum_getProvingStatus finalityProvisional, 0.3.16): locks are provisional right now
|
||||
pub provisional: bool,
|
||||
/// where the cause came from: "node" (the RPC's structured fields), "node-line" (its log line) or "" (the
|
||||
/// engine's own rule)
|
||||
pub cause_source: String,
|
||||
/// the node's own finalityActive (b2e21447), when it carries it: then the node decides `paused`, not the
|
||||
/// engine's 15-minute rule
|
||||
pub node_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind).
|
||||
|
|
@ -271,6 +325,15 @@ pub struct AddressState {
|
|||
pub source: String,
|
||||
pub key_saved: bool,
|
||||
pub wallet_file: String,
|
||||
/// Miner UI 4: the payout address's balance in wei as a decimal string (eth_getBalance through the node's own
|
||||
/// RPC, read every 30 s while the node runs); null until the first read; balance_age_s = -1 until then
|
||||
pub balance_wei: Option<String>,
|
||||
pub balance_age_s: f64,
|
||||
pub balance_note: String, // the last read's error, in words; "" when the last read was good
|
||||
/// £ per IGN. null until a market exists. Its one source will be a SIGNED field of the OTA manifest (`price`:
|
||||
/// gbp_per_ign, as_of, source), checked like the manifest's tuning object; the app never computes or fetches a
|
||||
/// price on its own
|
||||
pub price_gbp_per_ign: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
|
|
@ -297,6 +360,20 @@ pub struct SettingsState {
|
|||
pub tune_goal: String,
|
||||
pub power_price_pence: f64,
|
||||
pub tune_climb: bool,
|
||||
/// Ember Heat (src/heat.rs, config.rs): the region code, the heat-mode switch, the set point, the schedule as
|
||||
/// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset
|
||||
pub region: String,
|
||||
/// currency-21: the ISO 4217 override from the settings file ("" = follow the region)
|
||||
pub currency: String,
|
||||
/// the network step: the chosen network ("" = the package's own)
|
||||
pub network: String,
|
||||
pub heat_on: bool,
|
||||
pub heat_set_c: f64,
|
||||
pub heat_schedule: String,
|
||||
pub heat_tz_min: i32,
|
||||
pub heat_room_c: f64,
|
||||
pub heat_room_at: f64,
|
||||
pub heat_offset_c: f64,
|
||||
/// the manifest's tune period in seconds (tuning.ember.period_s, default 7 days): a card is due again at
|
||||
/// sweep_at + tune_period_s, or at once after a driver major or program-class change
|
||||
pub tune_period_s: u64,
|
||||
|
|
@ -304,6 +381,67 @@ pub struct SettingsState {
|
|||
pub dev_fee: bool,
|
||||
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
|
||||
pub proof_verify_trust: bool,
|
||||
/// miner-ui-5: the public address profile opt-in (settings.profile_public)
|
||||
pub profile_public: bool,
|
||||
/// ui-ota: "Use the built-in interface" (settings.ui_builtin)
|
||||
pub ui_builtin: bool,
|
||||
/// prove instead of mining on a card under 12 GB (settings.prove_instead)
|
||||
pub prove_instead: bool,
|
||||
}
|
||||
|
||||
/// The interface over the air (src/uiota.rs): what serves, from where, since when; Settings > Interface.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct UiState {
|
||||
/// the version compiled into this engine (ui/VERSION)
|
||||
pub embedded_version: String,
|
||||
/// the version the server serves now (the bundle's, or the embedded one)
|
||||
pub active_version: String,
|
||||
/// embedded | ota
|
||||
pub source: String,
|
||||
/// unix s the active bundle was swapped in (0 for the embedded interface)
|
||||
pub installed_at: f64,
|
||||
/// the page confirmed the active bundle with its health ping (always true for the embedded interface)
|
||||
pub confirmed: bool,
|
||||
/// the version the manifest publishes now ("" when the channel is withdrawn)
|
||||
pub published_version: String,
|
||||
pub busy: bool,
|
||||
pub error: String,
|
||||
/// versions refused here (never applied again)
|
||||
pub bad: Vec<String>,
|
||||
pub builtin: bool,
|
||||
/// why the published version is not applied, when it is not
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// Ember Heat (src/heat.rs): what the loop is doing, for the Cards strip, the Settings line and the Overview button.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct HeatState {
|
||||
pub on: bool,
|
||||
/// off | waiting | paused | heating | resting
|
||||
pub phase: String,
|
||||
/// the set point in force now (the schedule's slot, or the one set point)
|
||||
pub set_c: f64,
|
||||
/// the room estimate and where it came from: typed | card | none; the stated error; the reading's age
|
||||
pub room_c: f64,
|
||||
pub room_source: String,
|
||||
pub room_error_c: f64,
|
||||
pub room_age_s: f64,
|
||||
/// this period's duty (0 to 1) and the share of the last hour the cards heated
|
||||
pub duty: f64,
|
||||
pub duty_hour: f64,
|
||||
/// watts of heat now (the mining cards' draw; 0 while resting or with no draw reading), the cards' draw when
|
||||
/// they heat (the last reading), and the period's average (duty times that)
|
||||
pub heat_w: f64,
|
||||
pub full_w: f64,
|
||||
pub heat_avg_w: f64,
|
||||
/// seconds until the slice changes
|
||||
pub until_s: f64,
|
||||
/// the one line under the switch
|
||||
pub note: String,
|
||||
pub period_s: f64,
|
||||
/// the idle offset in use and whether a typed reading taught it
|
||||
pub offset_c: f64,
|
||||
pub offset_learned: bool,
|
||||
}
|
||||
|
||||
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
|
||||
|
|
@ -370,6 +508,9 @@ pub struct UpdateState {
|
|||
pub min_supported: String,
|
||||
pub channel: String,
|
||||
pub published_at: String,
|
||||
/// the network step: the manifest's default for a fresh install and whether the testnet is open
|
||||
pub default_network: String,
|
||||
pub testnet_open: bool,
|
||||
pub file: String, // the downloaded installer or disk image (the manual path opens it)
|
||||
pub updated_from: String, // set on the first run after an update
|
||||
pub rolled_back: String, // set when the helper restored the previous version
|
||||
|
|
@ -397,6 +538,11 @@ pub struct State {
|
|||
pub phase: String, // welcome | cards | address | dashboard
|
||||
pub setup_done: bool,
|
||||
pub network: String,
|
||||
/// the network step: the chain this engine runs by name (igneum-devnet-3, igneum-testnet-1, igneum-devnet-v4) and
|
||||
/// the one chosen for the next start when it differs ("" = none)
|
||||
pub network_name: String,
|
||||
pub network_pending: String,
|
||||
pub network_error: String,
|
||||
pub chain: String,
|
||||
pub host: String, // the hostname, a friendly label only
|
||||
pub display_name: String, // the user's name for this machine (settings), defaults to the hostname
|
||||
|
|
@ -413,14 +559,22 @@ pub struct State {
|
|||
pub clock: ClockState,
|
||||
pub address: AddressState,
|
||||
pub settings: SettingsState,
|
||||
pub heat: HeatState,
|
||||
pub dev_fee: DevFeeState,
|
||||
pub update: UpdateState,
|
||||
/// driver-check: the one install in flight (or the last), and the table's stamp
|
||||
pub drivers: crate::drivers::DriverState,
|
||||
pub ui: UiState,
|
||||
pub jobs: JobsState,
|
||||
pub events: Vec<Event>,
|
||||
pub uptime_s: u64,
|
||||
/// first-block-21: when this engine run began (unix s, the wall clock; uptime_s is monotonic and stops across a sleep)
|
||||
pub started_at: f64,
|
||||
pub now: f64,
|
||||
pub quitting: bool,
|
||||
pub live_page: String,
|
||||
/// miner-ui-5: this machine's own ladder record (src/ladder.rs), summarised at `now`
|
||||
pub ladder: crate::ladder::LadderState,
|
||||
}
|
||||
|
||||
/// Ring buffers behind the state: events (newest first in the JSON) and the log drawer.
|
||||
|
|
|
|||
|
|
@ -439,6 +439,9 @@ pub fn unsupported_reason(vendor: &str, power_default_w: f64, device: &str) -> O
|
|||
// Ember Tune (src/ember.rs, 5 October 2026): AMD is tuned through igneum-gpu-telemetry, Apple measures only;
|
||||
// the tune itself says which at its start (the card row's note)
|
||||
"apple" | "amd" => None,
|
||||
// the first Intel card, the B580 on PC 1 (7 October 2026, docs/plans/intel-arc.md section 2.3): OpenCL
|
||||
// exposes neither a cap nor a reading; Intel's path is IGCL (ControlLib.dll), the telemetry helper's next piece
|
||||
"intel" => Some("not available: Intel Arc exposes no power cap or reading through OpenCL (the driver's IGCL counters come next)"),
|
||||
_ => Some("not available: no power reading or cap for this card"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
533
app/igneum-app/src/uiota.rs
Normal file
533
app/igneum-app/src/uiota.rs
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
//! Interface over the air (docs/plans/ui-ota.md, 7 October 2026): the dashboard (app/igneum-app/ui, embedded in the
|
||||
//! engine binary) can be replaced by a signed bundle from the manifest's `ui` channel without a new app version.
|
||||
//!
|
||||
//! The flow, driven from the updater's hourly manifest (src/ota.rs hands the parsed `ui` entry over):
|
||||
//! decide: the entry is ignored when its min_engine is newer than this engine, when its version is the active
|
||||
//! one or the embedded one, when it was marked bad before, or when the miner chose the built-in interface
|
||||
//! -> download (curl to <app data>/ui/<version>.tar.gz, size and sha256 against the manifest, then the entry's own
|
||||
//! Ed25519 signature with the release key compiled into src/manifest.rs; the manifest's signature covered it too)
|
||||
//! -> unpack with the system tar into <app data>/ui/<version>.new, index.html, app.js and app.css must be there,
|
||||
//! rename to <app data>/ui/<version>
|
||||
//! -> swap: <app data>/ui/current.json names the version (written to .tmp and renamed: atomic); the server reads
|
||||
//! the pointer through Shared and serves the bundle's files in place of the embedded ones at the next page load;
|
||||
//! the open page sees state.ui.active_version change and reloads itself when idle
|
||||
//! -> confirm: the first page load from a new bundle starts a 10 s wait for the page's health ping
|
||||
//! (POST /api/ui/health after its first paint; a JS error on first paint posts the error instead); no ping, an
|
||||
//! error, or a bundle that fails to unpack rolls back to the embedded interface and marks the version bad
|
||||
//! (<app data>/ui/bad.json): it is never applied again
|
||||
//! The kill switch is the manifest without a `ui` object: the engine falls back to the embedded interface on the
|
||||
//! next check. Same origin, no remote script: the bundle is served from 127.0.0.1 by this engine like the embedded
|
||||
//! files, and the signature is the only trust.
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use crate::manifest::{self, UiEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The page's health ping must arrive this long after the first load of a new bundle.
|
||||
pub const HEALTH_WAIT_S: u64 = 10;
|
||||
/// The version of the interface compiled into this engine (app/igneum-app/ui/VERSION).
|
||||
pub const EMBEDDED_VERSION: &str = include_str!("../ui/VERSION");
|
||||
/// The files a bundle may serve: fixed names, nothing else is read from the bundle folder.
|
||||
pub const SERVED: [&str; 15] = ["index.html", "app.css", "app.js", "mark.svg", "live-dag.js", "proof-core.js", "VERSION", "fonts/IBMPlexMono-400.woff2", "fonts/IBMPlexMono-500.woff2", "fonts/IBMPlexSans-400.woff2", "fonts/IBMPlexSans-500.woff2", "fonts/IBMPlexSans-600.woff2", "fonts/Unbounded-500.woff2", "fonts/Unbounded-700.woff2", "fonts/Unbounded-900.woff2"];
|
||||
/// What a bundle must carry to be swapped in.
|
||||
const REQUIRED: [&str; 3] = ["index.html", "app.js", "app.css"];
|
||||
|
||||
pub fn embedded_version() -> &'static str {
|
||||
EMBEDDED_VERSION.trim()
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
/// the download, the checks and the unpack finished: the bundle folder, or why not (with the version)
|
||||
Installed(String, Result<PathBuf, String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
Apply,
|
||||
Skip(String),
|
||||
}
|
||||
|
||||
/// Whether a published entry is for this engine now (pure; the tests cover every branch).
|
||||
pub fn decide(e: &UiEntry, engine: &str, embedded: &str, active: Option<&str>, bad: &[String], builtin: bool) -> Decision {
|
||||
if builtin {
|
||||
return Decision::Skip("the built-in interface is chosen in Settings".into());
|
||||
}
|
||||
if manifest::newer(&e.min_engine, engine) {
|
||||
return Decision::Skip(format!("interface {} needs engine {} or newer (this is {engine})", e.version, e.min_engine));
|
||||
}
|
||||
if bad.iter().any(|b| b == &e.version) {
|
||||
return Decision::Skip(format!("interface {} failed here before and is not tried again", e.version));
|
||||
}
|
||||
if active == Some(e.version.as_str()) {
|
||||
return Decision::Skip(format!("interface {} is active", e.version));
|
||||
}
|
||||
if e.version == embedded {
|
||||
return Decision::Skip(format!("interface {} is the built-in one", e.version));
|
||||
}
|
||||
Decision::Apply
|
||||
}
|
||||
|
||||
/// The pointer file: which bundle the server serves ("embedded" or a version) and when it was swapped in.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Current {
|
||||
pub version: String,
|
||||
pub installed_at: u64,
|
||||
/// the page confirmed this bundle with a health ping
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
fn read_current(dir: &Path) -> Current {
|
||||
let v: serde_json::Value = std::fs::read_to_string(dir.join("current.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(serde_json::Value::Null);
|
||||
Current { version: v.get("version").and_then(|x| x.as_str()).unwrap_or("embedded").to_string(), installed_at: v.get("installed_at").and_then(|x| x.as_u64()).unwrap_or(0), confirmed: v.get("confirmed").and_then(|x| x.as_bool()).unwrap_or(false) }
|
||||
}
|
||||
|
||||
/// Writes the pointer atomically (the .tmp then the rename).
|
||||
pub fn write_current(dir: &Path, c: &Current) -> Result<(), String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let tmp = dir.join("current.json.tmp");
|
||||
std::fs::write(&tmp, serde_json::json!({ "version": c.version, "installed_at": c.installed_at, "confirmed": c.confirmed }).to_string()).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, dir.join("current.json")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn read_bad(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_to_string(dir.join("bad.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_bad(dir: &Path, bad: &[String]) {
|
||||
let _ = std::fs::write(dir.join("bad.json"), serde_json::to_string(bad).unwrap_or_default());
|
||||
}
|
||||
|
||||
/// The bundle folder for a version, if it holds what the server needs.
|
||||
pub fn bundle_dir(dir: &Path, version: &str) -> Option<PathBuf> {
|
||||
if version == "embedded" || version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let d = dir.join(version);
|
||||
if REQUIRED.iter().all(|f| d.join(f).is_file()) { Some(d) } else { None }
|
||||
}
|
||||
|
||||
/// Size, sha256 and the entry's own signature, then the unpack into <dir>/<version>: the folder on success.
|
||||
/// `pub_hex` is the release key (manifest::OTA_PUBLIC_KEY_HEX in the engine; the tests pass their own).
|
||||
pub fn install_bundle(e: &UiEntry, file: &Path, dir: &Path, pub_hex: &str) -> Result<PathBuf, String> {
|
||||
let size = std::fs::metadata(file).map(|m| m.len()).map_err(|er| format!("{}: {er}", file.display()))?;
|
||||
if size != e.size {
|
||||
return Err(format!("interface {}: the download is {size} bytes, the manifest says {}", e.version, e.size));
|
||||
}
|
||||
let sum = manifest::sha256_file(file).map_err(|er| er.to_string())?;
|
||||
if sum != e.sha256 {
|
||||
return Err(format!("interface {}: sha256 mismatch", e.version));
|
||||
}
|
||||
manifest::verify_ui_entry(e, pub_hex).map_err(|er| format!("interface {}: {er}", e.version))?;
|
||||
let fresh = dir.join(format!("{}.new", e.version));
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
std::fs::create_dir_all(&fresh).map_err(|er| er.to_string())?;
|
||||
let mut c = std::process::Command::new(crate::platform::tool("tar"));
|
||||
c.args(["-xzf", &file.display().to_string(), "-C", &fresh.display().to_string()]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("tar is not available")?;
|
||||
// a bundle packed with a top-level folder: take it
|
||||
let root = if REQUIRED.iter().all(|f| fresh.join(f).is_file()) {
|
||||
fresh.clone()
|
||||
} else {
|
||||
let inner = std::fs::read_dir(&fresh).ok().into_iter().flatten().filter_map(|d| d.ok()).map(|d| d.path()).find(|p| p.is_dir() && REQUIRED.iter().all(|f| p.join(f).is_file()));
|
||||
match inner {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
return Err(format!("interface {}: the bundle has no index.html, app.js and app.css ({})", e.version, out.lines().last().unwrap_or("tar said nothing")));
|
||||
}
|
||||
}
|
||||
};
|
||||
let dest = dir.join(&e.version);
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
std::fs::rename(&root, &dest).map_err(|er| format!("interface {}: {er}", e.version))?;
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
// the bundle's own VERSION must agree with the manifest (a renamed bundle is refused)
|
||||
let v = std::fs::read_to_string(dest.join("VERSION")).unwrap_or_default();
|
||||
if v.trim() != e.version {
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
return Err(format!("interface {}: the bundle's VERSION file says '{}'", e.version, v.trim()));
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// The engine's side: what is active, what is pending, the health wait, the rollback.
|
||||
pub struct UiOta {
|
||||
dir: PathBuf,
|
||||
current: Current,
|
||||
bad: Vec<String>,
|
||||
builtin: bool,
|
||||
busy: bool,
|
||||
/// the manifest entry seen last (for the Settings line and the retry after an error)
|
||||
entry: Option<UiEntry>,
|
||||
/// a bundle served to a page and not yet confirmed: (version, when the page loaded)
|
||||
waiting: Option<(String, Instant)>,
|
||||
error: String,
|
||||
/// the whole download thread's last reason, kept for the state
|
||||
last_skip: String,
|
||||
health_wait: Duration,
|
||||
}
|
||||
|
||||
impl UiOta {
|
||||
pub fn new(shared: &Arc<Shared>) -> UiOta {
|
||||
let dir = shared.runtime.app_dir.join("ui");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let builtin = shared.settings.lock().unwrap().ui_builtin;
|
||||
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: read_bad(&dir), builtin, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(HEALTH_WAIT_S) };
|
||||
// a pointer to a folder that is not there (a cleaned app data folder) falls back without a word
|
||||
if bundle_dir(&dir, &u.current.version).is_none() && u.current.version != "embedded" {
|
||||
shared.log(&format!("ui: the pointer names interface {} but its folder is gone; the built-in interface serves", u.current.version));
|
||||
u.current = Current { version: "embedded".into(), installed_at: 0, confirmed: true };
|
||||
let _ = write_current(&dir, &u.current);
|
||||
}
|
||||
u.apply_pointer(shared);
|
||||
if u.current.version != "embedded" {
|
||||
shared.log(&format!("ui bundle {} active (installed {}){}", u.current.version, u.current.installed_at, if u.builtin { ", but the built-in interface is chosen" } else { "" }));
|
||||
}
|
||||
u.publish(shared);
|
||||
u
|
||||
}
|
||||
|
||||
/// What the server serves: the bundle folder, or None for the embedded files.
|
||||
fn apply_pointer(&self, shared: &Arc<Shared>) {
|
||||
let d = if self.builtin { None } else { bundle_dir(&self.dir, &self.current.version) };
|
||||
shared.set_ui_dir(d);
|
||||
}
|
||||
|
||||
pub fn active_version(&self) -> &str {
|
||||
&self.current.version
|
||||
}
|
||||
|
||||
/// Called with every verified manifest: the `ui` entry or None (the kill switch).
|
||||
pub fn consider(&mut self, shared: &Arc<Shared>, entry: Option<&UiEntry>, engine: &str) {
|
||||
let Some(e) = entry else {
|
||||
self.entry = None;
|
||||
if self.current.version != "embedded" {
|
||||
shared.event("info", &format!("the interface channel was withdrawn; the built-in interface {} serves again", embedded_version()));
|
||||
shared.log(&format!("ui: no ui object in the manifest; interface {} retired", self.current.version));
|
||||
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
|
||||
}
|
||||
self.publish(shared);
|
||||
return;
|
||||
};
|
||||
self.entry = Some(e.clone());
|
||||
if self.busy {
|
||||
return;
|
||||
}
|
||||
match decide(e, engine, embedded_version(), Some(&self.current.version), &self.bad, self.builtin) {
|
||||
Decision::Skip(why) => {
|
||||
if why != self.last_skip {
|
||||
shared.log(&format!("ui: {why}"));
|
||||
self.last_skip = why;
|
||||
}
|
||||
}
|
||||
Decision::Apply => {
|
||||
self.last_skip = String::new();
|
||||
self.busy = true;
|
||||
self.error = String::new();
|
||||
shared.event("info", &format!("interface {} is published: downloading ({} KB)", e.version, e.size / 1000));
|
||||
let e2 = e.clone();
|
||||
let dir = self.dir.clone();
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = download_and_install(&e2, &dir);
|
||||
shared2.send(Cmd::UiOta(Event::Installed(e2.version.clone(), r)));
|
||||
});
|
||||
}
|
||||
}
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
pub fn event(&mut self, shared: &Arc<Shared>, ev: Event) {
|
||||
self.busy = false;
|
||||
match ev {
|
||||
Event::Installed(version, Ok(_dir)) => {
|
||||
shared.event("ok", &format!("interface {version} installed; the window takes it at its next load"));
|
||||
self.set_current(shared, Current { version: version.clone(), installed_at: crate::platform::unix_now(), confirmed: false });
|
||||
shared.log(&format!("ui bundle {version} active (installed {}), awaiting the page's health ping", self.current.installed_at));
|
||||
}
|
||||
Event::Installed(version, Err(e)) => {
|
||||
self.mark_bad(shared, &version, &e);
|
||||
}
|
||||
}
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
fn set_current(&mut self, shared: &Arc<Shared>, c: Current) {
|
||||
self.current = c;
|
||||
if let Err(e) = write_current(&self.dir, &self.current) {
|
||||
shared.log(&format!("ui: could not write the pointer: {e}"));
|
||||
}
|
||||
self.waiting = None;
|
||||
self.apply_pointer(shared);
|
||||
}
|
||||
|
||||
fn mark_bad(&mut self, shared: &Arc<Shared>, version: &str, why: &str) {
|
||||
if !self.bad.iter().any(|b| b == version) {
|
||||
self.bad.push(version.to_string());
|
||||
write_bad(&self.dir, &self.bad);
|
||||
}
|
||||
self.error = why.to_string();
|
||||
shared.event("error", &format!("interface {version} was refused: {why}. The built-in interface serves"));
|
||||
shared.log(&format!("ui: {version} marked bad: {why}"));
|
||||
if self.current.version == version {
|
||||
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// The server served index.html from a bundle: an unconfirmed one starts the health wait.
|
||||
pub fn page_loaded(&mut self, version: &str, now: Instant) {
|
||||
if version == self.current.version && !self.current.confirmed && self.waiting.is_none() {
|
||||
self.waiting = Some((version.to_string(), now));
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's ping after its first paint, or the error it caught before it.
|
||||
pub fn health(&mut self, shared: &Arc<Shared>, error: Option<&str>) {
|
||||
let Some((version, _)) = self.waiting.clone() else { return };
|
||||
match error {
|
||||
None => {
|
||||
self.waiting = None;
|
||||
self.current.confirmed = true;
|
||||
let _ = write_current(&self.dir, &self.current);
|
||||
shared.log(&format!("ui bundle {version} confirmed by the page"));
|
||||
self.publish(shared);
|
||||
}
|
||||
Some(e) => {
|
||||
self.mark_bad(shared, &version, &format!("a script error on first paint: {}", e.chars().take(200).collect::<String>()));
|
||||
self.publish(shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wait ran out: the page never confirmed the bundle.
|
||||
pub fn tick(&mut self, shared: &Arc<Shared>, now: Instant) {
|
||||
if let Some((version, since)) = self.waiting.clone() {
|
||||
if now.duration_since(since) >= self.health_wait {
|
||||
self.mark_bad(shared, &version, &format!("the page did not answer within {} s of loading it", self.health_wait.as_secs()));
|
||||
self.publish(shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_builtin(&mut self, shared: &Arc<Shared>, on: bool) {
|
||||
self.builtin = on;
|
||||
{
|
||||
let mut s = shared.settings.lock().unwrap();
|
||||
s.ui_builtin = on;
|
||||
s.save(&shared.settings_path);
|
||||
}
|
||||
shared.state.lock().unwrap().settings.ui_builtin = on;
|
||||
self.waiting = None;
|
||||
self.apply_pointer(shared);
|
||||
shared.event("info", if on { "the built-in interface serves from the next page load" } else { "the over-the-air interface serves again when one is active" });
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
/// Settings > Interface: what serves, from where, since when.
|
||||
pub fn publish(&self, shared: &Arc<Shared>) {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
let u = &mut st.ui;
|
||||
let ota_active = !self.builtin && bundle_dir(&self.dir, &self.current.version).is_some();
|
||||
u.embedded_version = embedded_version().into();
|
||||
u.active_version = if ota_active { self.current.version.clone() } else { embedded_version().into() };
|
||||
u.source = if ota_active { "ota".into() } else { "embedded".into() };
|
||||
u.installed_at = if ota_active { self.current.installed_at as f64 } else { 0.0 };
|
||||
u.confirmed = !ota_active || self.current.confirmed;
|
||||
u.published_version = self.entry.as_ref().map(|e| e.version.clone()).unwrap_or_default();
|
||||
u.busy = self.busy;
|
||||
u.error = self.error.clone();
|
||||
u.bad = self.bad.clone();
|
||||
u.builtin = self.builtin;
|
||||
u.note = self.last_skip.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// The download thread: curl with resume into <dir>/<version>.tar.gz, then install_bundle.
|
||||
fn download_and_install(e: &UiEntry, dir: &Path) -> Result<PathBuf, String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let file = dir.join(format!("{}.tar.gz", e.version));
|
||||
let part = dir.join(format!("{}.tar.gz.part", e.version));
|
||||
let mut c = std::process::Command::new(crate::platform::tool("curl"));
|
||||
c.args(["-fsSL", "--max-time", "300", "-C", "-", "-o", &part.display().to_string(), &e.url]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(320)).ok_or("curl is not available")?;
|
||||
let t = out.trim();
|
||||
if !t.is_empty() && !part.is_file() {
|
||||
return Err(format!("interface {}: {}", e.version, t.lines().last().unwrap_or("curl failed")));
|
||||
}
|
||||
std::fs::rename(&part, &file).map_err(|er| er.to_string())?;
|
||||
let r = install_bundle(e, &file, dir, manifest::OTA_PUBLIC_KEY_HEX);
|
||||
if r.is_err() {
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The content type a served bundle file gets (the same list the embedded files use).
|
||||
pub fn content_type(rel: &str) -> &'static str {
|
||||
if rel.ends_with(".html") { "text/html; charset=utf-8" } else if rel.ends_with(".css") { "text/css; charset=utf-8" } else if rel.ends_with(".js") { "application/javascript; charset=utf-8" } else if rel.ends_with(".svg") { "image/svg+xml" } else if rel.ends_with(".woff2") { "font/woff2" } else { "text/plain; charset=utf-8" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn entry(version: &str, min_engine: &str) -> UiEntry {
|
||||
UiEntry { version: version.into(), sha256: "ab".repeat(32), size: 1, url: "https://dl.igneum.network/dl/x/ui/igneum-ui-1.0.1.tar.gz".into(), min_engine: min_engine.into(), signature: "cd".repeat(64) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decision_covers_every_reason_to_skip() {
|
||||
let e = entry("1.0.1", "0.3.19");
|
||||
assert_eq!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], false), Decision::Apply);
|
||||
assert_eq!(decide(&e, "0.3.20", "1.0.0", Some("1.0.0"), &[], false), Decision::Apply);
|
||||
assert!(matches!(decide(&e, "0.3.18", "1.0.0", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("needs engine 0.3.19")), "a too-new min_engine is ignored");
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("1.0.1"), &[], false), Decision::Skip(w) if w.contains("is active")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.1", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("built-in one")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &["1.0.1".to_string()], false), Decision::Skip(w) if w.contains("failed here before")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], true), Decision::Skip(w) if w.contains("chosen in Settings")));
|
||||
}
|
||||
|
||||
/// A bundle folder, packed with the system tar, hashed and signed with a throwaway key.
|
||||
fn make_bundle(root: &Path, version: &str, with_index: bool, sk: &SigningKey) -> (UiEntry, PathBuf) {
|
||||
let src = root.join("src");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
std::fs::create_dir_all(src.join("fonts")).unwrap();
|
||||
if with_index {
|
||||
std::fs::write(src.join("index.html"), "<!doctype html><title>x</title>").unwrap();
|
||||
}
|
||||
std::fs::write(src.join("app.js"), "var x = 1;").unwrap();
|
||||
std::fs::write(src.join("app.css"), "body{}").unwrap();
|
||||
std::fs::write(src.join("VERSION"), format!("{version}\n")).unwrap();
|
||||
std::fs::write(src.join("fonts/IBMPlexMono-400.woff2"), b"wOF2").unwrap();
|
||||
let tar = root.join(format!("igneum-ui-{version}.tar.gz"));
|
||||
let mut c = std::process::Command::new("tar");
|
||||
c.args(["-czf", &tar.display().to_string(), "-C", &src.display().to_string(), "."]);
|
||||
let ok = crate::platform::quiet(&mut c).status().unwrap().success();
|
||||
assert!(ok, "tar packs the fixture");
|
||||
let sha = manifest::sha256_file(&tar).unwrap();
|
||||
let size = std::fs::metadata(&tar).unwrap().len();
|
||||
let sig = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(version, &sha, "0.3.19")).to_bytes());
|
||||
(UiEntry { version: version.into(), sha256: sha, size, url: "https://dl.igneum.network/dl/x/ui/x.tar.gz".into(), min_engine: "0.3.19".into(), signature: sig }, tar)
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("igneum-uiota-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_bundle_installs_and_the_pointer_swaps_atomically() {
|
||||
let root = tmp("good");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.1", true, &sk);
|
||||
let dir = root.join("ui");
|
||||
let dest = install_bundle(&e, &tar, &dir, &pk).expect("installs");
|
||||
assert_eq!(dest, dir.join("1.0.1"));
|
||||
assert!(bundle_dir(&dir, "1.0.1").is_some());
|
||||
assert!(!dir.join("1.0.1.new").exists(), "the staging folder is gone");
|
||||
write_current(&dir, &Current { version: "1.0.1".into(), installed_at: 5, confirmed: false }).unwrap();
|
||||
assert_eq!(read_current(&dir).version, "1.0.1");
|
||||
assert!(!dir.join("current.json.tmp").exists());
|
||||
assert_eq!(content_type("app.js"), "application/javascript; charset=utf-8");
|
||||
assert_eq!(bundle_dir(&dir, "embedded"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_signature_is_refused_before_anything_is_unpacked() {
|
||||
let root = tmp("badsig");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let other = manifest::hex_encode(SigningKey::from_bytes(&[8u8; 32]).verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.2", true, &sk);
|
||||
let dir = root.join("ui");
|
||||
let err = install_bundle(&e, &tar, &dir, &other).unwrap_err();
|
||||
assert!(err.contains("signature does not verify"), "{err}");
|
||||
assert!(!dir.join("1.0.2").exists());
|
||||
// a tampered hash is caught first
|
||||
let mut t = e.clone();
|
||||
t.sha256 = "00".repeat(32);
|
||||
let err = install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err();
|
||||
assert!(err.contains("sha256 mismatch"), "{err}");
|
||||
// a wrong size too
|
||||
let mut t = e.clone();
|
||||
t.size += 1;
|
||||
assert!(install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err().contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_bundle_without_index_html_is_refused_and_leaves_nothing_behind() {
|
||||
let root = tmp("broken");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.3", false, &sk);
|
||||
let dir = root.join("ui");
|
||||
let err = install_bundle(&e, &tar, &dir, &pk).unwrap_err();
|
||||
assert!(err.contains("no index.html"), "{err}");
|
||||
assert!(!dir.join("1.0.3").exists() && !dir.join("1.0.3.new").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_bundle_is_refused_by_its_version_file() {
|
||||
let root = tmp("renamed");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (mut e, tar) = make_bundle(&root, "1.0.4", true, &sk);
|
||||
e.version = "1.0.5".into();
|
||||
e.signature = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes("1.0.5", &e.sha256, "0.3.19")).to_bytes());
|
||||
let err = install_bundle(&e, &tar, &root.join("ui"), &pk).unwrap_err();
|
||||
assert!(err.contains("VERSION file says '1.0.4'"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_health_wait_rolls_back_to_the_embedded_interface_and_marks_the_version_bad() {
|
||||
// the state machine without threads: a UiOta over a temp dir, driven by hand
|
||||
let root = tmp("health");
|
||||
let dir = root.join("ui");
|
||||
std::fs::create_dir_all(dir.join("1.0.6")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.6").join(f), "x").unwrap();
|
||||
}
|
||||
write_current(&dir, &Current { version: "1.0.6".into(), installed_at: 1, confirmed: false }).unwrap();
|
||||
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: vec![], builtin: false, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(10) };
|
||||
let t0 = Instant::now();
|
||||
u.page_loaded("1.0.6", t0);
|
||||
assert!(u.waiting.is_some());
|
||||
// a ping in time confirms
|
||||
let shared = crate::engine::Shared::for_tests(root.join("data"));
|
||||
u.health(&shared, None);
|
||||
assert!(u.current.confirmed && u.waiting.is_none());
|
||||
assert_eq!(read_current(&dir).confirmed, true);
|
||||
// a second bundle that never answers
|
||||
std::fs::create_dir_all(dir.join("1.0.7")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.7").join(f), "x").unwrap();
|
||||
}
|
||||
u.set_current(&shared, Current { version: "1.0.7".into(), installed_at: 2, confirmed: false });
|
||||
u.page_loaded("1.0.7", t0);
|
||||
u.tick(&shared, t0 + Duration::from_secs(9));
|
||||
assert_eq!(u.current.version, "1.0.7", "still waiting inside the window");
|
||||
u.tick(&shared, t0 + Duration::from_secs(10));
|
||||
assert_eq!(u.current.version, "embedded", "rolled back");
|
||||
assert_eq!(read_current(&dir).version, "embedded");
|
||||
assert_eq!(read_bad(&dir), vec!["1.0.7".to_string()]);
|
||||
assert!(shared.ui_dir().is_none(), "the server serves the embedded files again");
|
||||
// a script error on first paint rolls back the same way
|
||||
std::fs::create_dir_all(dir.join("1.0.8")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.8").join(f), "x").unwrap();
|
||||
}
|
||||
u.set_current(&shared, Current { version: "1.0.8".into(), installed_at: 3, confirmed: false });
|
||||
u.page_loaded("1.0.8", t0);
|
||||
u.health(&shared, Some("TypeError: x is not a function"));
|
||||
assert_eq!(u.current.version, "embedded");
|
||||
assert!(u.bad.contains(&"1.0.8".to_string()));
|
||||
// and the decision never applies it again
|
||||
let e = entry("1.0.8", "0.3.19");
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &u.bad, false), Decision::Skip(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -40,22 +40,37 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
|||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
|
||||
/// unreachable node reads false: the block sample waits rather than asks.
|
||||
pub fn exec_has_record(evm_port: u16) -> bool {
|
||||
crate::execrpc::has_record(evm_port)
|
||||
}
|
||||
|
||||
/// The reading of an igneum_getExecStatus reply: a record is held when executedTipHash is a non-null string.
|
||||
pub fn exec_status_has_record(reply: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(reply).ok().map(|v| crate::execrpc::status_has_record(v.get("result").unwrap_or(&serde_json::Value::Null))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The latest block's timestamp (unix seconds) from the node's Ethereum JSON-RPC (the execution layer mirrors the
|
||||
/// consensus block times). The first clock source: local time against what the peers produced.
|
||||
pub fn latest_block_time(evm_port: u16) -> Option<f64> {
|
||||
let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}";
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]),
|
||||
None,
|
||||
Duration::from_secs(7),
|
||||
)?;
|
||||
let v: serde_json::Value = serde_json::from_str(&out).ok()?;
|
||||
let ts = v.get("result")?.get("timestamp")?.as_str()?;
|
||||
// 0.3.18/0.3.19 (ledger N7): through the one gate, which asks nothing of a follower without a record
|
||||
let block = crate::execrpc::call(evm_port, "eth_getBlockByNumber", serde_json::json!(["latest", false]), Duration::from_secs(5)).ok()?;
|
||||
let ts = block.get("timestamp")?.as_str()?;
|
||||
u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok().map(|t| t as f64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Ledger N7: the clock sample must not ask eth_getBlockByNumber of a follower with no record
|
||||
#[test]
|
||||
fn exec_status_gate() {
|
||||
assert!(super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec31b9227c3855a1b25ba50ee9f6815bf00b0befcc8836b430baf04343"}}"#));
|
||||
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x0","executedTipHash":null}}"#));
|
||||
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#));
|
||||
assert!(!super::exec_status_has_record(""));
|
||||
assert!(!super::exec_status_has_record("not json"));
|
||||
}
|
||||
#[test]
|
||||
fn http_date() {
|
||||
assert_eq!(super::parse_http_date("Sun, 04 Oct 2026 11:17:47 GMT"), Some(1_791_112_667.0));
|
||||
|
|
@ -79,6 +94,15 @@ pub fn upload_log(url: &str, key: &str, label: &str, machine: &str, run_id: &str
|
|||
let tail: String = String::from_utf8_lossy(&data).lines().filter(|l| !crate::platform::carries_token(l)).map(|l| crate::platform::redact(l)).collect::<Vec<_>>().join("\n");
|
||||
// the first line of every upload names the app, the machine and the node (the console parses it)
|
||||
let text = format!("{header}\n{tail}");
|
||||
upload_text(url, key, label, machine, run_id, &text)
|
||||
}
|
||||
|
||||
/// One upload of ready text (a fault report, a job's lines): the same intake, the same token guard.
|
||||
pub fn upload_text(url: &str, key: &str, label: &str, machine: &str, run_id: &str, text: &str) -> bool {
|
||||
if url.is_empty() || key.is_empty() || text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text: String = text.lines().filter(|l| !crate::platform::carries_token(l)).collect::<Vec<_>>().join("\n");
|
||||
let body = serde_json::json!({ "label": label, "machine": machine, "run_id": run_id, "lines": text });
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-upload-{}-{}.json", std::process::id(), label));
|
||||
if std::fs::write(&tmp, body.to_string()).is_err() {
|
||||
|
|
|
|||
|
|
@ -2,21 +2,79 @@
|
|||
//! rules can be unit-tested with recorded miner lines.
|
||||
//!
|
||||
//! Per card (`CardWatch`): a miner that prints no status line for 90 s, or reports a hash rate of 0 for 60 s while
|
||||
//! the node is synced, is restarted once; when that recurs before five minutes of healthy status, the card is marked
|
||||
//! faulted with the reason, its miner is not restarted again, and the other cards keep mining. The miner's own
|
||||
//! worker restarts (`WORKER FAULT`, `worker exited`) suspend both rules until the worker is ready again, so the app
|
||||
//! never restarts a miner that is already restarting its worker (no double restarts); if the worker is not back
|
||||
//! within 180 s the app steps in. Exit code 43 (the miner gave up on its worker after three guard trips) counts
|
||||
//! like a watchdog restart: once, then faulted.
|
||||
//! the node is ready, is restarted; when that recurs before five minutes of healthy status the next restart waits
|
||||
//! longer (10 s, 30 s, 2 min, 5 min, then every 5 min, for ever: the project lead, 7 October 2026, "it needs to be truly plug,
|
||||
//! tune, play"; no fault is permanent and the old one-restart-then-faulted state no longer exists). The reason stays on
|
||||
//! the card in plain words and the hash resumes on its own. A miner is judged only while the node is READY: synced
|
||||
//! and with an executed tip (`igneum_getExecStatus`); with no template it cannot print status, so every silence clock
|
||||
//! holds while the node catches up (PC 1, 7 October 2026: three workers faulted "no status line" during the node's
|
||||
//! catch-up and stayed faulted until a cards-API bounce). The miner's own worker restarts (`WORKER FAULT`,
|
||||
//! `worker exited`) suspend both rules until the worker is ready again, so the app never restarts a miner that is
|
||||
//! already restarting its worker (no double restarts); if the worker is not back within 180 s the app steps in.
|
||||
//! Exit code 43 (the miner gave up on its worker after three guard trips) is a watchdog restart on the same ladder.
|
||||
//!
|
||||
//! Per node (`NodeWatch`): a node of ours that answers no `watch` reading for 120 s is restarted by the app, with a
|
||||
//! growing delay when it repeats inside ten minutes.
|
||||
//! Per node (`NodeWatch`): a node of ours that gives no sign of life for 120 s is restarted by the app, with a
|
||||
//! growing delay when it repeats inside ten minutes. Its catch-up never counts: until the node has been read as
|
||||
//! synced once since its start, silence is not held against it (a node that never answers at all is restarted after
|
||||
//! 30 minutes), and any RPC answer (the watch reading, the exec status probe, an accepted block) is a sign of life.
|
||||
//!
|
||||
//! Review round 4, X21 (4 October 2026): the app now reads `mismatched=` and `faults=` from STATUS lines and the
|
||||
//! `WORKER FAULT` lines, and shows them on the card.
|
||||
|
||||
/// No status line from a running miner for this long: restart it.
|
||||
pub const NO_STATUS_S: f64 = 90.0;
|
||||
/// A worker that gives no status within this many seconds of its start, with the node synced, is one restart then
|
||||
/// faulted (PC 1, 7 October 2026 04:51Z: the row asks for a fault line at 60 s).
|
||||
pub const START_S: f64 = 60.0;
|
||||
/// The start of a restart reason the node's readiness, not the card, explains: the ladder resets when the node syncs.
|
||||
const NODE_FAULTS: [&str; 2] = ["no status line from the miner", "the worker gave no status within"];
|
||||
/// Seconds before the n-th restart of a card for a repeated fault (the last value repeats for ever).
|
||||
pub const RETRY_LADDER_S: [u64; 4] = [10, 30, 120, 300];
|
||||
/// A node that has never answered since its start is given this long before the watchdog restarts it.
|
||||
pub const NODE_STARTUP_CAP_S: f64 = 1800.0;
|
||||
/// A worker that has not reported its program loaded (`ready`) this long after its start is restarted on the ladder.
|
||||
/// The status clocks start at `ready`, never before (MF-4, 7 October 2026: two cards sat in "loading the program"
|
||||
/// behind a third card's export storm and were faulted at 90 s).
|
||||
pub const LOAD_S: f64 = 300.0;
|
||||
/// A miner that exits (any code but 0, 42, 43, 44) within this many seconds of its start is in a crash loop: its
|
||||
/// restarts follow the ladder instead of the 5 to 60 s jitter.
|
||||
pub const EARLY_EXIT_S: f64 = 120.0;
|
||||
/// A card whose worker failed its self-test is held this long before the next try (or until its driver changes).
|
||||
pub const SELF_TEST_HOLD_S: u64 = 1800;
|
||||
|
||||
/// MF-14 (PC 2, 7 October 2026 18:34 BST: two rows sat on "exporting this hour's program" for 18 minutes while a third
|
||||
/// card mined): a worker never waits on the program export longer than one retry interval without the reason shown;
|
||||
/// past two intervals the wait is over, the row names what blocked it and the worker retries on its own interval.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExportWait {
|
||||
/// inside one interval: the row keeps "exporting this hour's program"
|
||||
Waiting,
|
||||
/// past one interval: the row says how long and what blocks the export
|
||||
Say(String),
|
||||
/// past two intervals: the wait ends; the reason, for the restart on the ladder
|
||||
GiveUp(String),
|
||||
}
|
||||
|
||||
/// `waited_s` since the export was asked, `interval_s` the card's current retry interval (the ladder rung, at least
|
||||
/// 30 s), `blocker` what holds the export now (another card's export, the pack directory lock, the node not at the
|
||||
/// epoch, the last export error), or empty when nothing is known.
|
||||
pub fn export_wait(waited_s: f64, interval_s: f64, blocker: &str) -> ExportWait {
|
||||
let interval = interval_s.max(30.0);
|
||||
let what = if blocker.trim().is_empty() { "the export has not returned".to_string() } else { blocker.trim().to_string() };
|
||||
if waited_s >= 2.0 * interval {
|
||||
ExportWait::GiveUp(format!("the program export did not return in {} s ({what}); the worker retries on its own interval", waited_s as u64))
|
||||
} else if waited_s >= interval {
|
||||
ExportWait::Say(format!("exporting this hour's program: {} s so far ({what})", waited_s as u64))
|
||||
} else {
|
||||
ExportWait::Waiting
|
||||
}
|
||||
}
|
||||
|
||||
/// The delay before restart number `attempt` (1-based) of a card: the ladder, then its last step for ever.
|
||||
pub fn retry_delay_s(attempt: u32) -> u64 {
|
||||
let i = (attempt.max(1) as usize - 1).min(RETRY_LADDER_S.len() - 1);
|
||||
RETRY_LADDER_S[i]
|
||||
}
|
||||
/// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner.
|
||||
pub const ZERO_RATE_S: f64 = 60.0;
|
||||
/// The miner is restarting its own worker: the app waits this long for `ready` before it steps in.
|
||||
|
|
@ -45,6 +103,12 @@ pub struct Status {
|
|||
pub faults: u64,
|
||||
pub restarts: u64,
|
||||
pub synced: bool,
|
||||
/// seconds the miner has waited for a template with none known (`template_wait=`, 0.3.20 miners; 0 before)
|
||||
pub template_wait_s: f64,
|
||||
/// the node's last template time in ms (`template_ms=`; 0 when the line has none)
|
||||
pub template_ms: f64,
|
||||
/// identities the miner fetches for now (`identities_active=`; 0 when the line has none)
|
||||
pub identities_active: u64,
|
||||
}
|
||||
|
||||
/// Parses a miner STATUS line; None for any other line.
|
||||
|
|
@ -59,6 +123,9 @@ pub fn parse_status(text: &str) -> Option<Status> {
|
|||
faults: kv_u64(text, "faults").unwrap_or(0),
|
||||
restarts: kv_u64(text, "restarts").unwrap_or(0),
|
||||
synced: kv(text, "synced") == Some("true"),
|
||||
template_wait_s: kv_f64(text, "template_wait").unwrap_or(0.0),
|
||||
template_ms: kv_f64(text, "template_ms").unwrap_or(0.0),
|
||||
identities_active: kv_u64(text, "identities_active").unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -86,15 +153,26 @@ pub enum Event<'a> {
|
|||
Stopped,
|
||||
/// The user changed the card's settings: a faulted card may try again.
|
||||
Reset,
|
||||
/// The node became synced: a card faulted for silence while the node was not ready tries again (PC 1, 7 October
|
||||
/// 2026 04:51Z: every card faulted "no status line" during the post-relaunch sync and never came back).
|
||||
NodeSynced,
|
||||
/// The worker failed its self-test (MF-4): the card is held for `SELF_TEST_HOLD_S` with the reason on its row.
|
||||
SelfTestFailed(&'a str),
|
||||
/// The card's driver or platform changed (a re-enumeration): a held card tries again now.
|
||||
DriverChanged,
|
||||
/// The miner printed "template fetch timed out": it is alive and the node is not answering templates (PC 1,
|
||||
/// 7 October 2026 04:51Z: the node reported synced from the first second while its finality replay blocked the
|
||||
/// template RPC for three minutes; with no template the miner prints no status line). Silence is not the card's
|
||||
/// fault while this goes on; the miner is judged again once templates flow.
|
||||
TemplateTimeout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
None,
|
||||
/// Stop the miner and start it again now, for this reason.
|
||||
Restart(String),
|
||||
/// Mark the card faulted with this reason; do not restart its miner.
|
||||
Fault(String),
|
||||
/// Stop the miner and start it again after `delay_s`, for this reason (`attempt` counts restarts since the card
|
||||
/// was last healthy for five minutes).
|
||||
Restart { reason: String, delay_s: u64, attempt: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
@ -102,13 +180,27 @@ pub struct CardWatch {
|
|||
started_s: Option<f64>,
|
||||
last_status_s: Option<f64>,
|
||||
ready: bool,
|
||||
/// when the worker reported its program loaded: the status clocks start here
|
||||
ready_s: Option<f64>,
|
||||
/// held after a self-test failure until the driver changes or the hold passes
|
||||
driver_hold: bool,
|
||||
zero_since: Option<f64>,
|
||||
healthy_since: Option<f64>,
|
||||
worker_restart_since: Option<f64>,
|
||||
/// app-level restarts without five healthy minutes since
|
||||
/// app-level restarts without five healthy minutes since (the ladder position)
|
||||
restarts: u32,
|
||||
faulted: Option<String>,
|
||||
/// the reason of the last restart (a node-caused one resets the ladder when the node syncs)
|
||||
last_reason: String,
|
||||
last_fault: String,
|
||||
/// the miner's last line was a template timeout (the node not answering), cleared by the next status line
|
||||
templates_blocked: bool,
|
||||
}
|
||||
|
||||
impl CardWatch {
|
||||
/// True while the miner's last word was a template timeout: the node, not the card, holds the hashing.
|
||||
pub fn templates_blocked(&self) -> bool {
|
||||
self.templates_blocked
|
||||
}
|
||||
}
|
||||
|
||||
impl CardWatch {
|
||||
|
|
@ -116,10 +208,7 @@ impl CardWatch {
|
|||
Self::default()
|
||||
}
|
||||
|
||||
pub fn faulted(&self) -> Option<&str> {
|
||||
self.faulted.as_deref()
|
||||
}
|
||||
|
||||
/// Restarts since the card was last healthy for five minutes: the ladder position.
|
||||
pub fn watchdog_restarts(&self) -> u32 {
|
||||
self.restarts
|
||||
}
|
||||
|
|
@ -128,6 +217,7 @@ impl CardWatch {
|
|||
self.started_s = Some(now_s);
|
||||
self.last_status_s = None;
|
||||
self.ready = false;
|
||||
self.ready_s = None;
|
||||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
self.worker_restart_since = None;
|
||||
|
|
@ -140,14 +230,9 @@ impl CardWatch {
|
|||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
self.worker_restart_since = None;
|
||||
if self.restarts >= 1 {
|
||||
let r = format!("{reason} (restarted once already)");
|
||||
self.faulted = Some(r.clone());
|
||||
Action::Fault(r)
|
||||
} else {
|
||||
self.restarts += 1;
|
||||
Action::Restart(reason)
|
||||
}
|
||||
self.restarts = self.restarts.saturating_add(1);
|
||||
self.last_reason = reason.clone();
|
||||
Action::Restart { reason, delay_s: retry_delay_s(self.restarts), attempt: self.restarts }
|
||||
}
|
||||
|
||||
pub fn event(&mut self, now_s: f64, ev: Event<'_>) -> Action {
|
||||
|
|
@ -158,11 +243,22 @@ impl CardWatch {
|
|||
}
|
||||
Event::Ready => {
|
||||
self.ready = true;
|
||||
self.ready_s = Some(now_s);
|
||||
self.driver_hold = false;
|
||||
self.worker_restart_since = None;
|
||||
Action::None
|
||||
}
|
||||
Event::Status(s) => {
|
||||
self.last_status_s = Some(now_s);
|
||||
if s.template_wait_s > 0.0 && s.hash_now <= 0.0 {
|
||||
// MF-5: the miner is alive and waiting on the node for a template; a zero rate here is the
|
||||
// node's latency, never the card's fault
|
||||
self.templates_blocked = true;
|
||||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
return Action::None;
|
||||
}
|
||||
self.templates_blocked = false;
|
||||
if s.hash_now > 0.0 {
|
||||
self.zero_since = None;
|
||||
let since = *self.healthy_since.get_or_insert(now_s);
|
||||
|
|
@ -186,15 +282,35 @@ impl CardWatch {
|
|||
Action::None
|
||||
}
|
||||
Event::Exited(code) => {
|
||||
let running = self.started_s.is_some();
|
||||
let started = self.started_s;
|
||||
self.started_s = None;
|
||||
if code == MINER_GAVE_UP_CODE && running {
|
||||
if code == MINER_GAVE_UP_CODE && started.is_some() {
|
||||
let why = if self.last_fault.is_empty() { "its guards tripped three times in ten minutes".to_string() } else { self.last_fault.clone() };
|
||||
self.escalate(format!("the miner gave up on its worker: {why}"))
|
||||
} else {
|
||||
Action::None
|
||||
match started {
|
||||
// a crash loop: the ladder, not the 5 to 60 s jitter (MF-4)
|
||||
Some(t) if !matches!(code, 0 | 42 | 43 | PACK_OUT_OF_DATE_CODE) && now_s - t < EARLY_EXIT_S => {
|
||||
self.escalate(format!("the miner exited with code {code} {:.0} s after starting", now_s - t))
|
||||
}
|
||||
_ => Action::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::SelfTestFailed(reason) => {
|
||||
self.started_s = None;
|
||||
self.ready = false;
|
||||
self.driver_hold = true;
|
||||
self.last_reason = format!("not usable on this driver: {reason}");
|
||||
Action::Restart { reason: self.last_reason.clone(), delay_s: SELF_TEST_HOLD_S, attempt: self.restarts.max(1) }
|
||||
}
|
||||
Event::DriverChanged => {
|
||||
if self.driver_hold {
|
||||
self.driver_hold = false;
|
||||
self.restarts = 0;
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
Event::Stopped => {
|
||||
self.started_s = None;
|
||||
self.last_status_s = None;
|
||||
|
|
@ -207,14 +323,39 @@ impl CardWatch {
|
|||
*self = Self::default();
|
||||
Action::None
|
||||
}
|
||||
Event::TemplateTimeout => {
|
||||
// the miner's own heartbeat: every silence clock starts over from this line
|
||||
self.started_s = Some(now_s);
|
||||
if self.last_status_s.is_some() {
|
||||
self.last_status_s = Some(now_s);
|
||||
}
|
||||
self.zero_since = None;
|
||||
self.templates_blocked = true;
|
||||
Action::None
|
||||
}
|
||||
Event::NodeSynced => {
|
||||
// restarts the node's readiness explained do not count against the card
|
||||
if self.restarted_for_node() {
|
||||
self.restarts = 0;
|
||||
self.last_reason.clear();
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called every engine tick while the miner process is alive.
|
||||
pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action {
|
||||
if self.faulted.is_some() {
|
||||
return Action::None;
|
||||
}
|
||||
/// True while the card waits out a self-test failure (released by a driver change or the hold's end).
|
||||
pub fn driver_hold(&self) -> bool {
|
||||
self.driver_hold
|
||||
}
|
||||
|
||||
/// True when the last restart was for a reason the node's readiness explains (released by Event::NodeSynced).
|
||||
pub fn restarted_for_node(&self) -> bool {
|
||||
NODE_FAULTS.iter().any(|p| self.last_reason.starts_with(p))
|
||||
}
|
||||
|
||||
/// Called every engine tick while the miner process is alive. `node_ready`: synced AND an executed tip.
|
||||
pub fn tick(&mut self, now_s: f64, node_ready: bool) -> Action {
|
||||
let Some(started) = self.started_s else { return Action::None };
|
||||
if let Some(t) = self.worker_restart_since {
|
||||
// the miner is restarting its worker: its own guards own the card until the worker is ready
|
||||
|
|
@ -223,14 +364,32 @@ impl CardWatch {
|
|||
}
|
||||
return Action::None;
|
||||
}
|
||||
let last = self.last_status_s.unwrap_or(started);
|
||||
let node_synced = node_ready;
|
||||
if !node_synced {
|
||||
// a miner is judged only while the node is ready: with no template it cannot print status, so the silence
|
||||
// clocks (start, no status, zero rate) hold at now until the node is back (PC 1, 7 October 2026)
|
||||
self.started_s = Some(now_s);
|
||||
if self.last_status_s.is_some() {
|
||||
self.last_status_s = Some(now_s);
|
||||
}
|
||||
self.zero_since = None;
|
||||
return Action::None;
|
||||
}
|
||||
// the status clocks start when the worker reports its program loaded (MF-4), never before; loading itself
|
||||
// is bounded by LOAD_S
|
||||
let Some(ready_at) = self.ready_s else {
|
||||
if now_s - started > LOAD_S {
|
||||
return self.escalate(format!("the worker did not load its program within {} s of starting", LOAD_S as u64));
|
||||
}
|
||||
return Action::None;
|
||||
};
|
||||
if self.last_status_s.is_none() && now_s - ready_at > START_S {
|
||||
return self.escalate(format!("the worker gave no status within {} s of loading its program", START_S as u64));
|
||||
}
|
||||
let last = self.last_status_s.unwrap_or(ready_at);
|
||||
if now_s - last > NO_STATUS_S {
|
||||
return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64));
|
||||
}
|
||||
if !node_synced {
|
||||
// a zero rate while the node syncs is expected; the timer starts again once it is synced
|
||||
self.zero_since = None;
|
||||
}
|
||||
if node_synced && self.ready {
|
||||
if let Some(z) = self.zero_since {
|
||||
if now_s - z >= ZERO_RATE_S {
|
||||
|
|
@ -253,10 +412,15 @@ impl NodeWatch {
|
|||
Self::default()
|
||||
}
|
||||
|
||||
/// `silent_s`: seconds since the last reading (or since the node started, when it never answered). Returns the
|
||||
/// delay in seconds before the restart when one is due.
|
||||
pub fn tick(&mut self, now_s: f64, ours: bool, silent_s: f64, accepted_recent: bool) -> Option<u64> {
|
||||
if !ours || accepted_recent || silent_s < NODE_SILENT_S {
|
||||
/// `silent_s`: seconds since the node's last sign of life (a watch reading, an exec status answer, an accepted
|
||||
/// block; or since the node started, when it never answered). `settled`: the node has been read as synced at
|
||||
/// least once since its start; before that its catch-up never counts, only `NODE_STARTUP_CAP_S` of total silence
|
||||
/// does. Returns the delay in seconds before the restart when one is due.
|
||||
pub fn tick(&mut self, now_s: f64, ours: bool, silent_s: f64, accepted_recent: bool, settled: bool) -> Option<u64> {
|
||||
if !ours || accepted_recent {
|
||||
return None;
|
||||
}
|
||||
if silent_s < if settled { NODE_SILENT_S } else { NODE_STARTUP_CAP_S } {
|
||||
return None;
|
||||
}
|
||||
self.restarts_s.retain(|t| now_s - *t <= NODE_WINDOW_S);
|
||||
|
|
@ -403,7 +567,10 @@ mod tests {
|
|||
#[test]
|
||||
fn parses_status_and_fault_lines() {
|
||||
let s = parse_status(STATUS_OK).unwrap();
|
||||
assert_eq!(s, Status { hash_now: 124.10, mismatched: 0, faults: 0, restarts: 0, synced: true });
|
||||
assert_eq!(s, Status { hash_now: 124.10, mismatched: 0, faults: 0, restarts: 0, synced: true, template_wait_s: 0.0, template_ms: 0.0, identities_active: 0 });
|
||||
// a 0.3.20 miner waiting on a slow node (MF-5)
|
||||
let w = parse_status("1791151000.000 STATUS 'win-1' [worker]: 120s jobs=0 accepted=0 rejected=0 fee=0 mismatched=0 extra=0 rate=0.00 blocks/s hash=0.00 MH/s wall (0.00 MH/s inside jobs) now=0.00 MH/s wall (0.00 MH/s inside jobs, 0 jobs, seed walk 0 calls) template_age=0.00s synced=true idle=100.0% (last 10s: 100.0%) queued=0 restarts=0 faults=0 identities=24 accepted_by_identity=0 tip_age_s=0 template_wait=37s template_ms=8120 identities_active=1").unwrap();
|
||||
assert_eq!((w.template_wait_s, w.template_ms, w.identities_active), (37.0, 8120.0, 1));
|
||||
let m = parse_status(STATUS_MISMATCH).unwrap();
|
||||
assert_eq!((m.mismatched, m.faults, m.restarts), (3, 1, 1));
|
||||
assert_eq!(parse_status(STATUS_ZERO).unwrap().hash_now, 0.0);
|
||||
|
|
@ -422,6 +589,19 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn restart(a: &Action) -> (String, u64, u32) {
|
||||
match a {
|
||||
Action::Restart { reason, delay_s, attempt } => (reason.clone(), *delay_s, *attempt),
|
||||
Action::None => panic!("expected a restart, got None"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ladder() {
|
||||
assert_eq!((1..=6).map(retry_delay_s).collect::<Vec<_>>(), vec![10, 30, 120, 300, 300, 300]);
|
||||
assert_eq!(retry_delay_s(0), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_miner_is_left_alone() {
|
||||
let mut w = CardWatch::new();
|
||||
|
|
@ -431,61 +611,99 @@ mod tests {
|
|||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
}
|
||||
|
||||
/// the project lead's rule (7 October 2026): no fault is permanent. A zero rate restarts the miner on the ladder 10, 30,
|
||||
/// 120, 300, 300 ... s, the reason stays in plain words, and the hash resumes on its own when the worker is back.
|
||||
#[test]
|
||||
fn zero_rate_restarts_once_then_faults() {
|
||||
fn zero_rate_restarts_on_the_ladder_for_ever_and_the_hash_resumes() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 100.0);
|
||||
let z = parse_status(STATUS_ZERO).unwrap();
|
||||
for t in [110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None, "under 60 s at {t}");
|
||||
let mut t = 110.0;
|
||||
let mut seen = Vec::new();
|
||||
for expect in [(10u64, 1u32), (30, 2), (120, 3), (300, 4), (300, 5), (300, 6)] {
|
||||
// the app restarts it after the delay; still zero: the next rung
|
||||
w.event(t, Event::Started);
|
||||
w.event(t + 2.0, Event::Ready);
|
||||
let mut a = Action::None;
|
||||
let mut k = 0.0;
|
||||
while a == Action::None && k < 200.0 {
|
||||
w.event(t + 10.0 + k, Event::Status(&z));
|
||||
a = w.tick(t + 10.0 + k, true);
|
||||
k += 10.0;
|
||||
}
|
||||
let (reason, delay, attempt) = restart(&a);
|
||||
assert!(reason.starts_with("hash rate 0 for 60 s"), "{reason}");
|
||||
assert!(!reason.contains("once already"), "the permanent state no longer exists: {reason}");
|
||||
assert_eq!((delay, attempt), expect, "rung {}", expect.1);
|
||||
seen.push(delay);
|
||||
t += 10.0 + k + delay as f64;
|
||||
}
|
||||
w.event(170.0, Event::Status(&z));
|
||||
let a = w.tick(170.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("hash rate 0 for 60 s")), "{a:?}");
|
||||
// the app restarted it; still zero: faulted, not restarted again
|
||||
w.event(172.0, Event::Started);
|
||||
w.event(174.0, Event::Ready);
|
||||
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None);
|
||||
}
|
||||
w.event(240.0, Event::Status(&z));
|
||||
let a = w.tick(240.0, true);
|
||||
assert!(matches!(a, Action::Fault(ref r) if r.contains("restarted once already")), "{a:?}");
|
||||
assert!(w.faulted().is_some());
|
||||
// faulted stays: no more actions
|
||||
w.event(250.0, Event::Status(&z));
|
||||
assert_eq!(w.tick(260.0, true), Action::None);
|
||||
// the user changed the card's settings: a fresh start
|
||||
w.event(300.0, Event::Reset);
|
||||
assert!(w.faulted().is_none());
|
||||
assert_eq!(seen, vec![10, 30, 120, 300, 300, 300]);
|
||||
// the worker is healthy again: five healthy minutes and the ladder starts over at 10 s
|
||||
w.event(t, Event::Started);
|
||||
w.event(t + 2.0, Event::Ready);
|
||||
healthy(&mut w, t + 10.0, t + 320.0);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
let a = { let mut a = Action::None; let mut k = 0.0; while a == Action::None { w.event(t + 330.0 + k, Event::Status(&z)); a = w.tick(t + 330.0 + k, true); k += 10.0; } a };
|
||||
assert_eq!(restart(&a).1, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_rate_only_counts_with_a_synced_node_and_a_ready_worker() {
|
||||
fn zero_rate_only_counts_with_a_ready_node_and_a_ready_worker() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
let z = parse_status(STATUS_ZERO).unwrap();
|
||||
// not ready yet (program loading): no zero timer
|
||||
for t in [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0] {
|
||||
for t in [10.0, 20.0, 30.0, 40.0, 50.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None);
|
||||
}
|
||||
w.event(82.0, Event::Ready);
|
||||
// node not synced: no zero timer either
|
||||
for t in [90.0, 100.0, 110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
|
||||
w.event(52.0, Event::Ready);
|
||||
// node not ready (syncing, or no executed tip yet): no zero timer either
|
||||
for t in [60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, false), Action::None);
|
||||
}
|
||||
assert_eq!(w.tick(170.0, true), Action::None, "the timer starts at the first zero status after ready");
|
||||
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
|
||||
assert_eq!(w.tick(140.0, true), Action::None, "the timer starts at the first zero status after ready");
|
||||
for t in [150.0, 160.0, 170.0, 180.0, 190.0, 200.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
w.tick(t, true);
|
||||
}
|
||||
assert!(matches!(w.tick(240.0, true), Action::Restart(_)));
|
||||
assert!(matches!(w.tick(210.0, true), Action::Restart { .. }));
|
||||
}
|
||||
|
||||
/// PC 1, 7 October 2026: three workers started while the node caught up, printed nothing (no template), were
|
||||
/// restarted once and then marked faulted for good. Now: the silence clocks hold while the node is not ready, a
|
||||
/// restart the node explains does not climb the ladder once the node syncs, and nothing is ever permanent.
|
||||
#[test]
|
||||
fn a_worker_started_while_the_node_catches_up_is_never_faulted() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
// the node is not ready for ten minutes: no action, no climb
|
||||
let mut t = 1.0;
|
||||
while t < 600.0 {
|
||||
assert_eq!(w.tick(t, false), Action::None);
|
||||
t += 1.0;
|
||||
}
|
||||
// ready now, the worker has loaded but prints nothing: a restart after START_S, on rung 1
|
||||
w.event(t, Event::Ready);
|
||||
let mut a = Action::None;
|
||||
while a == Action::None && t < 700.0 {
|
||||
a = w.tick(t, true);
|
||||
t += 1.0;
|
||||
}
|
||||
let (reason, delay, attempt) = restart(&a);
|
||||
assert!(reason.starts_with("the worker gave no status within"), "{reason}");
|
||||
assert_eq!((delay, attempt), (10, 1));
|
||||
assert!(w.restarted_for_node());
|
||||
// the node drops back to catching up and syncs again: the ladder resets for a node-caused restart
|
||||
w.event(t, Event::NodeSynced);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
w.event(t + 10.0, Event::Started);
|
||||
w.event(t + 12.0, Event::Ready);
|
||||
healthy(&mut w, t + 20.0, t + 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -494,24 +712,64 @@ mod tests {
|
|||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
// the miner goes silent (a stopped process, a hung RPC)
|
||||
assert_eq!(w.tick(149.0, true), Action::None);
|
||||
let a = w.tick(151.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("no status line")), "{a:?}");
|
||||
let (reason, delay, _) = restart(&w.tick(151.0, true));
|
||||
assert!(reason.contains("no status line"), "{reason}");
|
||||
assert_eq!(delay, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_status_from_the_start() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
assert_eq!(w.tick(89.0, true), Action::None);
|
||||
assert!(matches!(w.tick(91.0, true), Action::Restart(_)));
|
||||
// loading: the status clock has not started; a worker that never loads is restarted at LOAD_S
|
||||
assert_eq!(w.tick(290.0, true), Action::None);
|
||||
let (reason, delay, _) = restart(&w.tick(301.0, true));
|
||||
assert!(reason.contains("did not load its program within 300 s"), "{reason}");
|
||||
assert_eq!(delay, 10);
|
||||
// loaded at 200 s (a slow self-test behind another card's export): the 60 s status clock starts there
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(200.0, Event::Ready);
|
||||
assert_eq!(w.tick(259.0, true), Action::None);
|
||||
let (reason, _, _) = restart(&w.tick(261.0, true));
|
||||
assert!(reason.contains("gave no status within 60 s of loading"), "{reason}");
|
||||
}
|
||||
|
||||
/// MF-4 (PC 1, 7 October 2026): a worker that fails its self-test is held for 30 minutes with the reason on its
|
||||
/// row, not restarted every few seconds; a driver change releases it; a miner in a crash loop climbs the ladder.
|
||||
#[test]
|
||||
fn self_test_failure_is_held_and_a_crash_loop_climbs_the_ladder() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
let (reason, delay, _) = restart(&w.event(3.0, Event::SelfTestFailed("3 of 96 vectors mismatched")));
|
||||
assert_eq!(reason, "not usable on this driver: 3 of 96 vectors mismatched");
|
||||
assert_eq!(delay, SELF_TEST_HOLD_S);
|
||||
assert!(w.driver_hold());
|
||||
w.event(100.0, Event::DriverChanged);
|
||||
assert!(!w.driver_hold());
|
||||
// a crash loop: exits 2 s after each start climb 10, 30, 120 s
|
||||
let mut w = CardWatch::new();
|
||||
let mut t = 0.0;
|
||||
let mut delays = Vec::new();
|
||||
for _ in 0..3 {
|
||||
w.event(t, Event::Started);
|
||||
let (reason, delay, _) = restart(&w.event(t + 2.0, Event::Exited(3)));
|
||||
assert!(reason.contains("exited with code 3"), "{reason}");
|
||||
delays.push(delay);
|
||||
t += 2.0 + delay as f64;
|
||||
}
|
||||
assert_eq!(delays, vec![10, 30, 120]);
|
||||
// an exit after a long healthy run is the engine's own jittered restart
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 600.0);
|
||||
assert_eq!(w.event(700.0, Event::Exited(3)), Action::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_miners_own_worker_restart_is_not_doubled() {
|
||||
// The gfx1036 fault: the miner prints WORKER FAULT, kills the worker, restarts it 2 s later; STATUS lines in
|
||||
// between say now=0. The app must not restart the miner on top of that.
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
|
|
@ -531,40 +789,86 @@ mod tests {
|
|||
w.event(t as f64, Event::Status(&z));
|
||||
assert_eq!(w.tick(t as f64, true), Action::None);
|
||||
}
|
||||
let a = w.tick(1091.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("did not come back within 180 s")), "{a:?}");
|
||||
let (reason, delay, _) = restart(&w.tick(1091.0, true));
|
||||
assert!(reason.contains("did not come back within 180 s"), "{reason}");
|
||||
assert_eq!(delay, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_43_once_then_faulted() {
|
||||
fn exit_43_is_a_restart_on_the_ladder() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
w.event(70.0, Event::WorkerRestart("cpu re-check: 3 consecutive mismatches (mismatched=3 in this run): the worker computes a wrong program"));
|
||||
let a = w.event(75.0, Event::Exited(43));
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("gave up") && r.contains("wrong program")), "{a:?}");
|
||||
w.event(80.0, Event::Started);
|
||||
let a = w.event(300.0, Event::Exited(43));
|
||||
assert!(matches!(a, Action::Fault(_)), "{a:?}");
|
||||
// an ordinary crash is the engine's own jittered restart, not the watchdog's
|
||||
let (reason, delay, attempt) = restart(&w.event(75.0, Event::Exited(43)));
|
||||
assert!(reason.contains("gave up") && reason.contains("wrong program"), "{reason}");
|
||||
assert_eq!((delay, attempt), (10, 1));
|
||||
w.event(85.0, Event::Started);
|
||||
let (_, delay, attempt) = restart(&w.event(300.0, Event::Exited(43)));
|
||||
assert_eq!((delay, attempt), (30, 2));
|
||||
// an exit 5 s after the start is the crash-loop class (MF-4): the ladder, not the 5 to 60 s jitter
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
assert_eq!(w.event(5.0, Event::Exited(1)), Action::None);
|
||||
assert!(matches!(w.event(5.0, Event::Exited(1)), Action::Restart { delay_s: 10, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_healthy_minutes_renew_the_budget() {
|
||||
fn template_timeouts_are_the_miners_heartbeat() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
assert!(matches!(w.tick(100.0, true), Action::Restart(_)));
|
||||
w.event(101.0, Event::Started);
|
||||
w.event(103.0, Event::Ready);
|
||||
healthy(&mut w, 110.0, 420.0);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
// the node stops answering templates for four minutes while the app still reads it as ready
|
||||
let mut t = 70.0;
|
||||
while t < 300.0 {
|
||||
w.event(t, Event::TemplateTimeout);
|
||||
assert_eq!(w.tick(t + 1.0, true), Action::None, "a heartbeat at {t} is not silence");
|
||||
t += 5.0;
|
||||
}
|
||||
assert!(w.templates_blocked());
|
||||
healthy(&mut w, 310.0, 400.0);
|
||||
assert!(!w.templates_blocked());
|
||||
}
|
||||
|
||||
/// MF-5 (PC 1, 7 October 2026): the node answered templates past 5 s; the miner now prints STATUS every interval
|
||||
/// with template_wait= while it waits, and the watchdog measures the worker, never the node.
|
||||
#[test]
|
||||
fn a_slow_node_never_faults_the_card() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
let slow = Status { hash_now: 0.0, template_wait_s: 8.0, template_ms: 8120.0, identities_active: 1, synced: true, ..Default::default() };
|
||||
let mut t = 70.0;
|
||||
while t < 700.0 {
|
||||
w.event(t, Event::Status(&slow));
|
||||
assert_eq!(w.tick(t, true), Action::None, "waiting on the node at {t} is not a fault");
|
||||
t += 10.0;
|
||||
}
|
||||
assert!(w.templates_blocked());
|
||||
healthy(&mut w, 710.0, 800.0);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
// a second incident later is again a restart, not a fault
|
||||
assert!(matches!(w.tick(520.0, true), Action::Restart(_)));
|
||||
}
|
||||
|
||||
/// MF-14: known-failed first (an export that never returns), then the known-good shapes.
|
||||
#[test]
|
||||
fn an_export_that_never_returns_is_named_and_given_up_within_two_intervals() {
|
||||
// the PC 2 shape: 18 minutes on "exporting" behind another card's export; with a 30 s interval the row
|
||||
// names the blocker at 30 s and the wait ends at 60 s
|
||||
assert_eq!(export_wait(31.0, 30.0, "another card's export holds the pack lock (Intel Arc B580, 31 s)"), ExportWait::Say("exporting this hour's program: 31 s so far (another card's export holds the pack lock (Intel Arc B580, 31 s))".into()));
|
||||
match export_wait(1080.0, 30.0, "") {
|
||||
ExportWait::GiveUp(r) => { assert!(r.starts_with("the program export did not return in 1080 s (the export has not returned)")); assert!(r.ends_with("retries on its own interval")); }
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(export_wait(60.0, 30.0, "the node is not at the epoch yet"), ExportWait::GiveUp(_)));
|
||||
// a normal export (5 to 25 s) says nothing
|
||||
assert_eq!(export_wait(5.0, 30.0, ""), ExportWait::Waiting);
|
||||
assert_eq!(export_wait(25.0, 120.0, ""), ExportWait::Waiting);
|
||||
// a longer rung widens the wait, never below 30 s
|
||||
assert_eq!(export_wait(100.0, 120.0, "x"), ExportWait::Waiting);
|
||||
assert!(matches!(export_wait(125.0, 120.0, "x"), ExportWait::Say(_)));
|
||||
assert!(matches!(export_wait(45.0, 10.0, "x"), ExportWait::Say(_)), "the floor is 30 s even on the 10 s rung");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -575,18 +879,26 @@ mod tests {
|
|||
w.event(30.0, Event::Stopped);
|
||||
assert_eq!(w.tick(500.0, true), Action::None);
|
||||
assert_eq!(w.event(500.0, Event::Exited(0)), Action::None);
|
||||
w.event(600.0, Event::Reset);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
}
|
||||
|
||||
/// The node's catch-up never counts against its watchdog (PC 1, 7 October 2026: a 40-second restart loop).
|
||||
#[test]
|
||||
fn node_watch_restarts_a_silent_node_with_growing_delay() {
|
||||
fn node_watch_waits_out_a_catch_up_and_restarts_a_dead_node_with_growing_delay() {
|
||||
let mut n = NodeWatch::new();
|
||||
assert_eq!(n.tick(100.0, true, 119.0, false), None);
|
||||
assert_eq!(n.tick(100.0, false, 500.0, false), None, "an external node is never restarted");
|
||||
assert_eq!(n.tick(100.0, true, 500.0, true), None, "our block was accepted in the last minute: the node is alive");
|
||||
assert_eq!(n.tick(100.0, true, 120.0, false), Some(3));
|
||||
assert_eq!(n.tick(300.0, true, 120.0, false), Some(12));
|
||||
assert_eq!(n.tick(500.0, true, 120.0, false), Some(48));
|
||||
// catching up (never read as synced since its start): 25 minutes of silence is not a restart
|
||||
assert_eq!(n.tick(100.0, true, 1500.0, false, false), None);
|
||||
// a node that never answers at all: restarted at the 30-minute cap
|
||||
assert_eq!(n.tick(100.0, true, 1801.0, false, false), Some(3));
|
||||
let mut n = NodeWatch::new();
|
||||
assert_eq!(n.tick(100.0, true, 119.0, false, true), None);
|
||||
assert_eq!(n.tick(100.0, false, 500.0, false, true), None, "an external node is never restarted");
|
||||
assert_eq!(n.tick(100.0, true, 500.0, true, true), None, "our block was accepted in the last minute: the node is alive");
|
||||
assert_eq!(n.tick(100.0, true, 120.0, false, true), Some(3));
|
||||
assert_eq!(n.tick(300.0, true, 120.0, false, true), Some(12));
|
||||
assert_eq!(n.tick(500.0, true, 120.0, false, true), Some(48));
|
||||
assert_eq!(n.restarts_in_window(), 3);
|
||||
assert_eq!(n.tick(2000.0, true, 120.0, false), Some(3), "the window passed");
|
||||
assert_eq!(n.tick(2000.0, true, 120.0, false, true), Some(3), "the window passed");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
app/igneum-app/ui/VERSION
Normal file
1
app/igneum-app/ui/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.2
|
||||
|
|
@ -14,23 +14,30 @@
|
|||
@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')}
|
||||
|
||||
/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
|
||||
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}}
|
||||
/* scene-tokens:end */
|
||||
: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);--nvidia:#8BE37A;--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);--ok:#FFB35C;
|
||||
|
||||
--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);--nvidia:#8BE37A;--intel:#7CC4FF;--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);--ok:#FFB35C;
|
||||
/* 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;--drawer-h:260px;--rail:196px}
|
||||
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px;
|
||||
/* the chain scene's inclusion colours (EMBER 02 live-dag.js reads them from :root) */
|
||||
}
|
||||
@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);--nvidia:#3C9B2C;--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);--ok:#B8731F}}
|
||||
|
||||
--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);--nvidia:#3C9B2C;--intel:#1C6FD6;--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);--ok:#B8731F;}}
|
||||
: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);--nvidia:#3C9B2C;--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);--ok:#B8731F}
|
||||
|
||||
--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);--nvidia:#3C9B2C;--intel:#1C6FD6;--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);--ok:#B8731F;}
|
||||
*{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}
|
||||
|
|
@ -61,7 +68,7 @@ input,textarea{font-variant-numeric:tabular-nums}
|
|||
.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.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)}
|
||||
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
|
||||
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
|
||||
|
|
@ -77,7 +84,7 @@ body.mac .rail-brand{padding-top:30px}
|
|||
.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{background:var(--hover);border-color:var(--line);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)}
|
||||
|
|
@ -99,13 +106,15 @@ 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}
|
||||
body.has-rail .top{align-items:baseline;padding-top:19px}
|
||||
.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}
|
||||
.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{display:inline-flex;align-items:baseline;gap:var(--s-2);font-family:var(--sans);font-size:var(--t-base);font-weight:600;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:5px 12px 5px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;justify-content:center;line-height:1.3}
|
||||
.pill .dot{position:relative;top:-1px}
|
||||
.pill.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.pill.warn{color:var(--ember)}
|
||||
.pill.warn{color:var(--ember);border-color:var(--ember-40)}
|
||||
.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}
|
||||
|
|
@ -116,26 +125,30 @@ body.has-rail .top{left:var(--rail)}
|
|||
top moves once per change with a 150 ms transition (layoutStrip), never per poll. */
|
||||
.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.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)}
|
||||
.notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
|
||||
.notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
|
||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-3);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--row);border-bottom:1px solid var(--line);box-shadow:inset 0 1px 0 var(--ember);font-size:var(--t-base);line-height:1.4;color:var(--ash)}
|
||||
.notice-dot{width:7px;height:7px;border-radius:50%;background:var(--ember);flex:0 0 7px;display:inline-block}
|
||||
.notice.bad .notice-text,.notice.update-urgent .notice-text{color:var(--bone)}
|
||||
.notice.update-urgent .notice-text{font-weight:600}
|
||||
.notice .notice-text b{color:var(--bone);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.update-urgent .notice-close{color:#fff}
|
||||
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
|
||||
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
|
||||
.notice-more{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
|
||||
.notice-more:hover{color:var(--bone);border-color:var(--line-2)}
|
||||
.notice-more .chev{width:8px;height:8px;transform:rotate(45deg) translateY(-2px)}
|
||||
.notice-more.open .chev{transform:rotate(225deg) translateY(-2px)}
|
||||
.notice-detail{flex-basis:100%;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);white-space:normal;word-break:break-word;margin-top:-2px;user-select:text;-webkit-user-select:text}
|
||||
.notice .prog{flex-basis:100%;height:2px;background:var(--line);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{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--row);border:1px solid var(--line);border-radius:14px;padding:12px 16px;box-shadow:inset 0 1px 0 var(--ember),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}
|
||||
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none}
|
||||
.ask.inline{box-shadow:inset 0 1px 0 var(--ember);background:var(--row);margin-top:var(--s-3);animation:none}
|
||||
|
||||
/* 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}
|
||||
|
|
@ -143,7 +156,7 @@ main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overfl
|
|||
body.has-rail main{left:var(--rail)}
|
||||
body.drawer-open main{bottom:var(--drawer-h)}
|
||||
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
|
||||
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
|
||||
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="region"] #screen-region,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{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-dashboard{padding:22px 0 32px}
|
||||
|
|
@ -186,11 +199,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
|
||||
/* GPU rows (first run) */
|
||||
.gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap}
|
||||
.gpu-row.off{opacity:.72}
|
||||
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 44px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.badge.apple{color:var(--bone)}
|
||||
.badge.nvidia{color:var(--nvidia)}
|
||||
.badge.amd{color:var(--ember)}
|
||||
.gpu-row.off>.badge,.gpu-row.off>.switch,.gpu-row.off .info>:not(.driver){opacity:.72}
|
||||
.gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)}
|
||||
.gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500;white-space:nowrap}
|
||||
|
|
@ -214,7 +223,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||||
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
|
||||
.toggle-big.stop:hover{border-color:var(--ash);background:var(--row)}
|
||||
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
|
||||
.toggle-big.stop .ring{background:var(--obsidian);border:1px solid var(--line-2);color:var(--ember)}
|
||||
.toggle-big.stop .ts{color:var(--molten);opacity:1}
|
||||
.totals{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;min-width:0}
|
||||
.tot{display:flex;flex-direction:column;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
|
||||
|
|
@ -246,15 +255,16 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.feed>div:last-child{border-bottom:0}
|
||||
.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
||||
.feed .k{color:var(--molten);margin-right:6px}
|
||||
.feed .k.error,.feed .k.warn,.feed .k.block{color:var(--ember)}
|
||||
.feed .k.build,.feed .k.info{color:var(--ash)}
|
||||
.feed .k{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--molten);margin-right:10px;position:relative;top:-1px}
|
||||
.feed .k.error,.feed .k.warn,.feed .k.block{background:var(--ember)}
|
||||
.feed .k.build,.feed .k.info{background:var(--line-2)}
|
||||
.feed .k.proving{background:var(--nvidia)}
|
||||
|
||||
/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron;
|
||||
the tune line under; the details under that */
|
||||
.gpu-list{display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.gpu-line{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0}
|
||||
.gpu-line.off{opacity:.62}
|
||||
.gpu-line.off .gl-main,.gpu-line.off .gl-tune,.gpu-line.off .gl-details{opacity:.62}
|
||||
.gpu-line.open{border-color:var(--line-2)}
|
||||
.gl-main{display:grid;grid-template-columns:44px minmax(150px,1.2fr) auto auto;align-items:center;gap:var(--s-4);padding:12px 14px}
|
||||
.gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
|
|
@ -282,6 +292,21 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.gl-tune{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);border-top:1px solid var(--line)}
|
||||
.gl-tune .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.gl-tune .n{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.gl-driver{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-size:var(--t-base);color:var(--ink-2);border-top:1px solid var(--line)}
|
||||
.gl-driver .t{flex:1 1 320px;min-width:0;color:var(--bone)}
|
||||
.gl-driver .n{flex-basis:100%;font-size:var(--t-sm);line-height:1.4}
|
||||
.gl-driver.offer .t,.gl-driver.reboot .t{font-weight:600}
|
||||
.gl-driver.error .t{color:var(--ember)}
|
||||
.gl-driver.note{color:var(--ash)}
|
||||
.gl-driver.note .t{color:var(--ink-2);font-weight:400}
|
||||
.gl-driver .bar{flex-basis:100%;height:4px;border-radius:2px;background:var(--line-2);overflow:hidden;display:block}
|
||||
.gl-driver .bar b{display:block;height:100%;background:var(--ember);transition:width .4s}
|
||||
.gpu-row .driver{margin-top:6px;font-size:var(--t-base);color:var(--bone);display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap}
|
||||
.gpu-row .driver .n{flex-basis:100%;font-size:var(--t-sm);color:var(--ash);line-height:1.4}
|
||||
.gpu-row .driver.error .t{color:var(--ember)}
|
||||
.gpu-row .driver.note{color:var(--ink-2);font-weight:400}
|
||||
.gpu-row .driver .bar{flex-basis:100%;height:4px;border-radius:2px;background:var(--line-2);overflow:hidden;display:block}
|
||||
.gpu-row .driver .bar b{display:block;height:100%;background:var(--ember)}
|
||||
.gl-tune.running{color:var(--molten)}
|
||||
.gl-tune.stopped,.gl-tune.paused{color:var(--ember)}
|
||||
.gl-tune .bar{flex-basis:100%;height:3px;border-radius:2px;background:var(--line);overflow:hidden;display:block}
|
||||
|
|
@ -349,18 +374,26 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
#s-jobs-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||
#s-jobs-history tr.failed td,#s-jobs-history tr.timeout td,#s-jobs-history tr.aborted td{color:var(--ember)}
|
||||
#s-jobs-history tr.running td{color:var(--molten)}
|
||||
.clock-card{border:1px solid var(--ember-40);background:var(--ember-12);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.clock-card.warn{border-color:var(--molten-40);background:var(--molten-10)}
|
||||
.clock-card{border:1px solid var(--line);background:var(--row);box-shadow:inset 0 1px 0 var(--ember);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.clock-card.warn{box-shadow:inset 0 1px 0 var(--molten)}
|
||||
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
|
||||
.clock-card .note{margin-top:0}
|
||||
|
||||
/* earnings: money first */
|
||||
.money{display:flex;flex-direction:column;margin-bottom:var(--s-2)}
|
||||
.money-row{display:flex;align-items:baseline;gap:var(--s-3);padding:8px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||
.money-row:last-child{border-bottom:0}
|
||||
.money-row .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;letter-spacing:-.01em;min-width:200px}
|
||||
.money-row .v.small{font-size:var(--t-num)}
|
||||
.money-row .s{font-size:var(--t-base);color:var(--ash);min-width:0}
|
||||
/* earnings (earnings-tidy, 7 October 2026): the day rate first in ember with its reason line, the run line, then a quiet
|
||||
row of three (weight, electricity, lifetime) in the totals' idiom, the dev-fee switch last */
|
||||
.earn{display:flex;flex-direction:column;margin-bottom:var(--s-3)}
|
||||
.earn-head{display:flex;flex-direction:column;gap:4px;padding-bottom:var(--s-3);border-bottom:1px solid var(--line)}
|
||||
.earn-head .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;letter-spacing:-.01em;color:var(--ember);text-wrap:balance}
|
||||
.earn-head .s,.earn-run .s{font-size:var(--t-base);color:var(--ash);line-height:1.45;min-width:0}
|
||||
.earn-run{display:flex;align-items:baseline;gap:var(--s-3);flex-wrap:wrap;padding:var(--s-3) 0;border-bottom:1px solid var(--line)}
|
||||
.earn-run .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.15;white-space:nowrap}
|
||||
.earn-three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4);padding-top:var(--s-3)}
|
||||
.earn-cell{display:flex;flex-direction:column;gap:3px;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
|
||||
.earn-cell:first-child{padding-left:0;border-left:0}
|
||||
.earn-cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
|
||||
.earn-cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.earn-cell .s{font-size:var(--t-sm);color:var(--ash);line-height:1.4}
|
||||
@media (max-width:720px){.earn-head .v{font-size:var(--t-h2)}.earn-three{grid-template-columns:1fr;gap:var(--s-2)}.earn-cell{padding-left:0;border-left:0;border-top:1px solid var(--line);padding-top:var(--s-2)}.earn-cell:first-child{border-top:0;padding-top:0}}
|
||||
|
||||
/* prove */
|
||||
.tier-list{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column;gap:6px;font-size:var(--t-md);color:var(--ink-2)}
|
||||
|
|
@ -372,7 +405,7 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
|
||||
.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
|
||||
.option:hover{border-color:var(--line-2)}
|
||||
.option.on{border-color:var(--ember);background:var(--ember-12)}
|
||||
.option.on{border-color:var(--ember);background:var(--row)}
|
||||
.option input[type=radio]{position:absolute;opacity:0;width:0;height:0}
|
||||
.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative}
|
||||
.option.on .radio{border-color:var(--ember)}
|
||||
|
|
@ -384,6 +417,15 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text}
|
||||
.addr-input.short{width:100px;flex:0 0 100px}
|
||||
.addr-input:focus{outline:none;border-color:var(--ember)}
|
||||
/* Ember Heat (mission item 7): the region list, the restricted line, the heat line on the strip and the rows, the rent row */
|
||||
.addr-input.select{appearance:none;-webkit-appearance:none;max-width:360px;padding-right:36px;background-image:linear-gradient(45deg,transparent 50%,var(--ash) 50%),linear-gradient(135deg,var(--ash) 50%,transparent 50%);background-position:calc(100% - 20px) 50%,calc(100% - 14px) 50%;background-size:6px 6px,6px 6px;background-repeat:no-repeat;cursor:pointer}
|
||||
.addr-input.select option{background:var(--graphite);color:var(--bone)}
|
||||
.restricted{font-size:var(--t-md);line-height:1.5;color:var(--bone);background:var(--ember-12);border:1px solid var(--ember-40);border-left:3px solid var(--ember);border-radius:10px;padding:10px 14px;max-width:64ch;margin:0}
|
||||
.heat-line{font-family:var(--mono);font-size:var(--t-sm)}
|
||||
.heat-line.heat,.line-text.ok{color:var(--molten)}
|
||||
.heat-line.rest,.line-text.rest{color:var(--ash)}
|
||||
.field.grow{flex:1 1 260px;min-width:0}
|
||||
.gl-tune .n.heat{color:var(--molten)}
|
||||
.option:not(.on) .addr-input{display:none}
|
||||
.err{font-size:var(--t-base);color:var(--ember)}
|
||||
|
||||
|
|
@ -440,18 +482,21 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
.row{display:flex;gap:10px;align-items:center;min-width:0}
|
||||
.row.wrap{flex-wrap:wrap}
|
||||
.row .addr-input{margin-top:0;min-width:0}
|
||||
.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}
|
||||
/* the custom switch (Prove page fix, 7 October 2026): the label is the positioned ancestor, the native input is hidden
|
||||
the accessible way (1 px, clipped, still focusable, the label association kept), the knob lives inside the track at
|
||||
both states; the size class is the switch's own (big), never the DAG legend's swatch class */
|
||||
.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;position:relative}
|
||||
.switch+.switch{border-top:1px solid var(--line)}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
.switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
|
||||
.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}
|
||||
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
|
||||
.switch.lg .track::after{width:24px;height:24px}
|
||||
.switch.lg input:checked+.track::after{transform:translateX(22px)}
|
||||
.switch.big .track{width:52px;height:30px;flex-basis:52px}
|
||||
.switch.big .track::after{width:24px;height:24px}
|
||||
.switch.big input:checked+.track::after{transform:translateX(22px)}
|
||||
.sw-text{min-width:0}
|
||||
.sw-text b{font-weight:600}
|
||||
.sw-text .dim{font-size:var(--t-base)}
|
||||
|
|
@ -506,8 +551,8 @@ body.drawer-drag main{pointer-events:none}
|
|||
.log .src.app{color:var(--ember)}
|
||||
.log .src.watch{color:var(--ash)}
|
||||
.log .e,.log .e .src{color:var(--ember)}
|
||||
.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
|
||||
.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px}
|
||||
.log .ln.hit{background:var(--hover);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
|
||||
.log mark{background:transparent;color:var(--bone);text-decoration:underline;text-decoration-color:var(--ember);text-underline-offset:2px}
|
||||
.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center}
|
||||
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px var(--shadow);z-index:2;gap:6px;padding:6px 12px}
|
||||
.log-jump:hover{border-color:var(--molten)}
|
||||
|
|
@ -547,7 +592,6 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
|||
h1{font-size:40px}
|
||||
.totals{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:var(--s-3)}
|
||||
.tot:nth-child(3){padding-left:0;border-left:0}
|
||||
.money-row .v{min-width:0}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
:root{--rail:0px;--gutter:var(--s-4);--bottom:64px}
|
||||
|
|
@ -588,8 +632,9 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
|||
.disclose-right .dim{display:none}
|
||||
.lead-row{flex-direction:column}
|
||||
.step{padding:32px 0}
|
||||
.money-row .v{font-size:var(--t-num)}
|
||||
.drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)}
|
||||
.notice{padding-left:var(--s-4);padding-right:var(--s-2);gap:var(--s-2)}
|
||||
.notice-text{flex-basis:160px}
|
||||
.log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)}
|
||||
}
|
||||
/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */
|
||||
|
|
@ -605,3 +650,239 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
|||
.drawer-head{padding-bottom:6px}
|
||||
.toggle-big{min-height:96px}
|
||||
}
|
||||
|
||||
/* miner-ui-4 (6 October 2026): Overview and Cards. The Overview hero (the project lead's pick, C): the fleet rate at 84 px on a
|
||||
graphite-to-obsidian fade with W, £ a day and blocks beside it, Start/Stop as the full-width bar under it. */
|
||||
.hero-row{grid-template-columns:1fr}
|
||||
.totals{order:1;grid-template-columns:2fr 1fr 1fr 1fr;align-items:end;padding:28px 28px 24px;border-color:transparent;background:linear-gradient(180deg,var(--graphite),var(--obsidian))}
|
||||
.tot{border-left:0;padding-left:0}
|
||||
.tot:first-child .v{font-size:84px;line-height:.95;letter-spacing:-.03em}
|
||||
.tot:first-child .k{font-size:var(--t-sm);margin-top:10px}
|
||||
.tot:first-child .s{font-size:var(--t-md)}
|
||||
.tot .v{font-size:30px}
|
||||
.tot .v.ask-price{font-size:var(--t-md);padding:9px 0 8px}
|
||||
.toggle-big{order:2;min-height:64px;flex-direction:row;align-items:center;gap:14px;padding:12px 20px}
|
||||
.toggle-big .ring{margin:0}
|
||||
.toggle-big .ts{margin-left:auto}
|
||||
/* the chain scene: the site's live-dag.js in a 220 px box, the legend words under it, the blocks strip as the fallback */
|
||||
.dag-wrap{position:relative}
|
||||
#dag-live{display:block;width:100%;height:220px;border-radius:10px;background:var(--obsidian);border:1px solid var(--line)}
|
||||
#dag{height:120px;margin-bottom:0}
|
||||
.dag-tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;font-size:var(--t-xs);color:var(--ink-2);display:flex;flex-direction:column;gap:2px;max-width:320px;box-shadow:0 8px 24px var(--shadow);z-index:3}
|
||||
.dag-tip b{color:var(--bone);font-weight:500;word-break:break-all}
|
||||
.dag-chip{position:absolute;right:10px;top:10px;font-size:var(--t-xs);color:var(--ash);background:var(--graphite);border:1px solid var(--line-2);border-radius:999px;padding:3px 10px;pointer-events:none;z-index:2}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
|
||||
.legend span{display:inline-flex;align-items:center;gap:7px}
|
||||
/* the DAG legend swatches, scoped to the legend (a bare .lg reached label.switch.lg until 7 October 2026) */
|
||||
/* the key's swatches (key-22, 7 October 2026): the entries, order and tokens come from IgneumDag.legend; a swatch takes its
|
||||
colour from --lg (set by renderLegend to the entry's token) and its shape from the class, so no state is coloured here */
|
||||
.legend .lg{--lg:var(--ash);width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--lg);background:var(--lg-fill,var(--row));position:relative;flex:none}
|
||||
.legend .lg.faded{opacity:.45}
|
||||
.legend .lg.circle{border-radius:50%;box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
|
||||
.legend .lg.ringed{box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
|
||||
.legend .lg.tick{border-color:var(--line-2)}
|
||||
.legend .lg.tick::after{content:"\2713";position:absolute;left:0;top:-4px;font-size:11px;line-height:1;color:var(--lg)}
|
||||
/* Cards: the Tuning strip above the list */
|
||||
.tune-strip{padding:var(--s-4) var(--card-pad)}
|
||||
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}
|
||||
.ts-row .goal-line{flex:0 1 auto}
|
||||
.ts-saving{font-size:var(--t-base);color:var(--molten);font-weight:500;margin-left:auto}
|
||||
.ts-saving:empty{display:none}
|
||||
.tune-strip .line-text{margin-top:var(--s-2);font-size:var(--t-base);color:var(--ash)}
|
||||
.tune-strip .switch{padding-bottom:0}
|
||||
/* the Ember layer on a row: the flame mark, the saving, the sparkline, the curve */
|
||||
.gl-tune{align-items:center}
|
||||
.flame{flex:0 0 16px;display:inline-block}
|
||||
.flame .fo{fill:none;stroke:var(--ash);stroke-width:7}
|
||||
.flame .ff{fill:var(--ember)}
|
||||
.flame.running .ff{fill:var(--molten)}
|
||||
.flame.measured .ff{fill:var(--ash)}
|
||||
.flame.idle .ff,.flame.paused .ff,.flame.stopped .ff{fill:none}
|
||||
.gl-tune.tuned .t{color:var(--ink-2)}
|
||||
.gl-tune .save,.gl-details .save{color:var(--molten);white-space:nowrap}
|
||||
.num .k.spark{display:inline-flex;align-items:center;gap:6px}
|
||||
.spark{display:inline-block;vertical-align:middle}
|
||||
.spark path{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.spark circle{fill:var(--ember)}
|
||||
.curve-wrap{display:flex;flex-direction:column;gap:6px;max-width:420px}
|
||||
.curve-svg{width:100%;height:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px}
|
||||
.curve-svg .ln{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.curve-svg circle{fill:var(--ember)}
|
||||
.curve-svg circle.chosen{fill:none;stroke:var(--ember);stroke-width:2}
|
||||
.curve-svg circle.marked{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.curve-svg text{font-family:var(--mono);font-size:9px;fill:var(--ash)}
|
||||
.seg.small .seg-b{padding:4px 10px;font-size:var(--t-sm)}
|
||||
.gl-details .ctl .seg{justify-self:start}
|
||||
@media (max-width:1180px){
|
||||
.totals{grid-template-columns:1fr 1fr 1fr 1fr}
|
||||
.tot:first-child .v{font-size:64px}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.totals{grid-template-columns:1fr 1fr;row-gap:var(--s-4)}
|
||||
.tot:first-child{grid-column:1 / -1}
|
||||
.tot:nth-child(3){padding-left:0}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.tot:first-child .v{font-size:56px}
|
||||
.toggle-big .ts{display:none}
|
||||
#dag-live{height:170px}
|
||||
.ts-saving{margin-left:0}
|
||||
.legend{gap:6px 12px}
|
||||
.rail-foot .nav.small{padding:6px 4px}
|
||||
}
|
||||
|
||||
/* ---- miner-ui-5: the miner's first month: the count-up, the block card, the ladder strip, the rungs, the timeline ---- */
|
||||
.wait-card{background:var(--row);padding:16px 20px}
|
||||
.wait-row{display:flex;align-items:center;gap:var(--s-4)}
|
||||
.wait-ring{position:relative;width:52px;height:52px;flex:0 0 52px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.wait-ring svg{position:absolute;inset:0;width:52px;height:52px;transform:rotate(-90deg)}
|
||||
.wait-ring .track{fill:none;stroke:var(--line);stroke-width:3}
|
||||
.wait-ring .arc{fill:none;stroke:var(--molten);stroke-width:3;stroke-linecap:round;stroke-dasharray:97.4;stroke-dashoffset:97.4;transition:stroke-dashoffset .6s ease}
|
||||
.wait-ring .pct{font-size:var(--t-xs);color:var(--molten)}
|
||||
.wait-text .t{font-size:var(--t-md);color:var(--bone)}
|
||||
.wait-text .s{font-size:var(--t-sm);color:var(--ash);margin-top:2px}
|
||||
.block-card{position:relative;background:linear-gradient(135deg,var(--graphite),var(--row));border-color:var(--line-2);box-shadow:inset 0 1px 0 var(--ember),0 18px 50px var(--shadow);user-select:text;-webkit-user-select:text}
|
||||
.bc-head{display:flex;align-items:center;gap:var(--s-3);margin-bottom:var(--s-2)}
|
||||
.bc-when{margin-left:auto;font-size:var(--t-xs);color:var(--ash)}
|
||||
.bc-close{position:static;margin-left:4px}
|
||||
.bc-title{font-size:var(--t-h1);letter-spacing:-.02em;margin-bottom:var(--s-2)}
|
||||
.bc-title.small{font-size:var(--t-h2)}
|
||||
.bc-line{font-size:var(--t-lg);color:var(--ink-2);margin:2px 0}
|
||||
.bc-line.dim{color:var(--ash);font-size:var(--t-md)}
|
||||
.bc-hash{font-size:var(--t-sm);color:var(--ash);margin:8px 0 12px;word-break:break-all}
|
||||
.shard-card{box-shadow:inset 0 1px 0 var(--nvidia)}
|
||||
.ladder-strip{padding:14px 20px}
|
||||
.ls-rungs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.ls-rung{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.04em;color:var(--ash);padding:4px 10px 4px 6px;border:1px solid var(--line);border-radius:999px;white-space:nowrap}
|
||||
.ls-rung i{width:8px;height:8px;border-radius:50%;background:var(--line-2);display:inline-block}
|
||||
.ls-rung.done{color:var(--molten);border-color:var(--molten-40)}
|
||||
.ls-rung.done i{background:var(--molten)}
|
||||
.ls-rung.now{color:var(--bone);border-color:var(--ember-40)}
|
||||
.ls-rung.now i{background:var(--ember);box-shadow:0 0 0 3px var(--ember-12)}
|
||||
.ls-rung.off{opacity:.55}
|
||||
.ls-line{display:flex;align-items:baseline;gap:var(--s-3);flex-wrap:wrap}
|
||||
.ls-line .t{font-size:var(--t-md);color:var(--bone);font-weight:500}
|
||||
.ls-line .s{font-size:var(--t-sm);min-width:0}
|
||||
.ls-line .btn{margin-left:auto}
|
||||
.rungs{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column}
|
||||
.rung{display:grid;grid-template-columns:22px 1fr;gap:var(--s-3);padding:10px 0;border-bottom:1px solid var(--line)}
|
||||
.rung:last-child{border-bottom:0}
|
||||
.rg-dot{width:12px;height:12px;border-radius:50%;background:var(--line-2);margin-top:5px;border:2px solid transparent;position:relative}
|
||||
.rung.done .rg-dot{background:var(--molten)}
|
||||
.rung.now .rg-dot{background:var(--ember);box-shadow:0 0 0 4px var(--ember-12)}
|
||||
.rung.off .rg-dot{background:transparent;border-color:var(--line-2)}
|
||||
.rg-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.rg-name{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.rg-line{font-size:var(--t-lg);color:var(--bone)}
|
||||
.rung.done .rg-line{color:var(--molten)}
|
||||
.rung.next .rg-line,.rung.off .rg-line{color:var(--ash)}
|
||||
.rg-sub{font-size:var(--t-sm);color:var(--ash);line-height:1.45}
|
||||
.rung .bar{display:block;height:3px;border-radius:2px;background:var(--line);overflow:hidden;margin-top:6px;max-width:320px}
|
||||
.rung .bar b{display:block;height:100%;background:var(--ember)}
|
||||
.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
|
||||
.timeline li{display:grid;grid-template-columns:140px auto 1fr;gap:var(--s-3);align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);color:var(--ash);font-size:var(--t-md)}
|
||||
.timeline li:last-child{border-bottom:0}
|
||||
.timeline li.done{color:var(--bone)}
|
||||
.timeline .tl-at{font-size:var(--t-sm);color:var(--molten);white-space:nowrap}
|
||||
.timeline .tl-note{font-size:var(--t-sm);min-width:0}
|
||||
.num.ign .v{color:var(--molten)}
|
||||
@media (max-width:720px){.bc-title{font-size:var(--t-h2)}.timeline li{grid-template-columns:110px auto 1fr}.ls-line .btn{margin-left:0}}
|
||||
|
||||
/* ---- the full chain scene with the block inspector (EMBER 02; the words of the site's /live) ---- */
|
||||
.chain-full{margin-top:var(--s-3);border-top:1px solid var(--line);padding-top:var(--s-3)}
|
||||
.cf-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);flex-wrap:wrap;margin-bottom:var(--s-3)}
|
||||
.cf-right{display:inline-flex;align-items:center;gap:var(--s-2)}
|
||||
.zoom{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
||||
.zoom button{background:transparent;border:0;color:var(--ink-2);font:400 13px/1 var(--mono);padding:7px 11px;cursor:pointer}
|
||||
.zoom button:hover{background:var(--hover)}
|
||||
.zoom span{font-size:var(--t-xs);color:var(--ash);padding:0 6px;min-width:44px;text-align:center}
|
||||
.cf-graph{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:var(--s-4)}
|
||||
.cf-scene{position:relative;min-width:0}
|
||||
.cf-scene canvas{display:block;width:100%;height:420px;border-radius:10px;background:var(--row);border:1px solid var(--line)}
|
||||
.cf-foot{margin-top:8px;font-size:var(--t-xs);color:var(--ash)}
|
||||
.inspector{min-width:0;font-size:var(--t-base);border-left:1px solid var(--line);padding-left:var(--s-4)}
|
||||
.inspector .ih{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.chip{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);border:1px solid var(--line);border-radius:4px;padding:4px 8px;white-space:nowrap}
|
||||
.ititle{font-family:var(--sans);font-weight:500;font-size:20px;line-height:1.2;color:var(--bone);margin:6px 0 4px;letter-spacing:-.01em}
|
||||
.ihash{font-size:var(--t-xs);color:var(--ash);overflow-wrap:anywhere;margin-bottom:12px;user-select:text;-webkit-user-select:text}
|
||||
.proof-scene{height:180px;border:1px solid var(--line);border-radius:6px;background:var(--row);margin:10px 0;overflow:hidden}
|
||||
.proof-scene canvas{display:block;width:100%;height:100%}
|
||||
.proof-head{display:flex;justify-content:space-between;align-items:baseline;font-weight:500;color:var(--bone);margin-bottom:8px}
|
||||
/* the inspector's shard track, scoped to its element (a bare .track reached every switch's track until 7 October 2026) */
|
||||
#i-track{display:flex;gap:3px;height:3px;margin-bottom:10px}
|
||||
#i-track i{flex:1 1 0;background:var(--line);border-radius:2px}
|
||||
#i-track i.proving{background:var(--ember)}#i-track i.verified{background:var(--bone)}#i-track i.paid{background:var(--molten)}
|
||||
.shards{list-style:none;margin:0 0 12px;padding:0;font-size:var(--t-xs);line-height:1.7;max-height:120px;overflow:auto}
|
||||
.shards li{display:flex;justify-content:space-between;gap:8px;color:var(--ink-2)}
|
||||
.shards li::before{content:"\25CF";font-size:7px;margin-right:6px;color:var(--line-2)}
|
||||
.shards li.proving,.shards li.proving::before{color:var(--ember)}.shards li.verified,.shards li.verified::before{color:var(--bone)}.shards li.paid,.shards li.paid::before{color:var(--molten)}
|
||||
.shards li.muted{color:var(--ash)}
|
||||
.insp{display:grid;grid-template-columns:auto minmax(0,1fr);gap:0 10px;margin:0;border-top:1px solid var(--line);padding-top:6px;font-size:var(--t-xs)}
|
||||
.insp dt{color:var(--ash);padding:5px 0}
|
||||
.insp dd{margin:0;color:var(--bone);text-align:right;padding:5px 0;overflow-wrap:anywhere;min-width:0}
|
||||
.inspector .note{font-size:var(--t-sm);margin-top:12px;padding-top:10px;border-top:1px solid var(--line)}
|
||||
.inspector .top-gap{margin-top:8px}
|
||||
@media (max-width:900px){.cf-graph{grid-template-columns:1fr}.inspector{border-left:0;padding-left:0;border-top:1px solid var(--line);padding-top:var(--s-3)}}
|
||||
|
||||
/* ---------- vendor marks (gpu-logos, 7 October 2026): a self-contained block, the last thing in the file ----------
|
||||
One simplified monochrome glyph per GPU vendor (View.vendorMark in app.js) in a soft rounded well: the vendor colour
|
||||
at low alpha behind it, a hairline ring in the same colour, the glyph in the full colour. The tokens are the
|
||||
module's own (--mark-*), one set per theme, so the light hex of every vendor reads at 3:1 or better on its well
|
||||
(view.test.mjs computes the ratio and checks the hex here). The ember accent is for state and never tints a brand.
|
||||
Nothing animates: hover and focus-within only deepen the ring. One mark per row, drawn by View.markHtml alone. */
|
||||
:root{--mark-nvidia:#8BE37A;--mark-nvidia-well:rgba(139,227,122,.14);--mark-nvidia-ring:rgba(139,227,122,.45);--mark-amd:#FF5A5A;--mark-amd-well:rgba(255,90,90,.14);--mark-amd-ring:rgba(255,90,90,.45);--mark-intel:#7CC4FF;--mark-intel-well:rgba(124,196,255,.14);--mark-intel-ring:rgba(124,196,255,.45);--mark-apple:#E6E3DD;--mark-apple-well:rgba(230,227,221,.14);--mark-apple-ring:rgba(230,227,221,.45);--mark-gpu:#9A9A9E;--mark-gpu-well:rgba(154,154,158,.14);--mark-gpu-ring:rgba(154,154,158,.45)}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}}
|
||||
:root[data-theme="light"]{--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}
|
||||
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex:0 0 44px;border:1px solid var(--mark-gpu-ring);color:var(--mark-gpu);background:var(--mark-gpu-well);box-shadow:0 0 0 0 transparent;transition:box-shadow .15s ease,border-color .15s ease}
|
||||
.badge svg{width:22px;height:22px;display:block}
|
||||
.badge.nvidia{color:var(--mark-nvidia);background:var(--mark-nvidia-well);border-color:var(--mark-nvidia-ring)}
|
||||
.badge.amd{color:var(--mark-amd);background:var(--mark-amd-well);border-color:var(--mark-amd-ring)}
|
||||
.badge.intel{color:var(--mark-intel);background:var(--mark-intel-well);border-color:var(--mark-intel-ring)}
|
||||
.badge.apple{color:var(--mark-apple);background:var(--mark-apple-well);border-color:var(--mark-apple-ring)}
|
||||
.badge.gpu{color:var(--mark-gpu);background:var(--mark-gpu-well);border-color:var(--mark-gpu-ring)}
|
||||
.gpu-row:hover .badge,.gpu-line:hover .badge,.gpu-row:focus-within .badge,.gpu-line:focus-within .badge{border-color:currentColor;box-shadow:0 0 0 3px var(--mark-gpu-well)}
|
||||
.gpu-row:hover .badge.nvidia,.gpu-line:hover .badge.nvidia,.gpu-row:focus-within .badge.nvidia,.gpu-line:focus-within .badge.nvidia{box-shadow:0 0 0 3px var(--mark-nvidia-well)}
|
||||
.gpu-row:hover .badge.amd,.gpu-line:hover .badge.amd,.gpu-row:focus-within .badge.amd,.gpu-line:focus-within .badge.amd{box-shadow:0 0 0 3px var(--mark-amd-well)}
|
||||
.gpu-row:hover .badge.intel,.gpu-line:hover .badge.intel,.gpu-row:focus-within .badge.intel,.gpu-line:focus-within .badge.intel{box-shadow:0 0 0 3px var(--mark-intel-well)}
|
||||
.gpu-row:hover .badge.apple,.gpu-line:hover .badge.apple,.gpu-row:focus-within .badge.apple,.gpu-line:focus-within .badge.apple{box-shadow:0 0 0 3px var(--mark-apple-well)}
|
||||
.badge.mini{width:26px;height:26px;border-radius:8px;flex:0 0 26px;display:inline-flex;vertical-align:middle;margin-right:8px}
|
||||
.badge.mini svg{width:15px;height:15px}
|
||||
@media (prefers-reduced-motion:reduce){.badge{transition:none}}
|
||||
/* the series line under the name: mono, small, quiet */
|
||||
.gen{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.gpu-row .gen{margin-top:-2px}
|
||||
.gpu-line .who .gen{margin:-1px 0 1px}
|
||||
#bc-card{display:flex;align-items:center;min-width:0}
|
||||
#bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
||||
.help.ask-cur{color:var(--ember)}
|
||||
|
||||
/* the network step (0.3.23): two cards, the chosen one in ember, the one running marked, a closed one dimmed */
|
||||
.net-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s-3);margin:var(--s-3) 0}
|
||||
.net-card{display:flex;flex-direction:column;gap:4px;text-align:left;padding:14px 16px;border-radius:12px;border:1px solid var(--line-2);background:var(--row);color:var(--bone);cursor:pointer;min-width:0}
|
||||
.net-card:hover{border-color:var(--ash)}
|
||||
.net-card[aria-checked="true"]{border-color:var(--ember);box-shadow:inset 0 0 0 1px var(--ember)}
|
||||
.net-card[disabled]{opacity:.55;cursor:default}
|
||||
.net-card .nc-name{font-family:var(--head);font-weight:700;font-size:var(--t-md)}
|
||||
.net-card .nc-line{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;color:var(--ash)}
|
||||
.net-card .nc-sub{font-size:var(--t-sm);color:var(--ash);line-height:1.4}
|
||||
.net-card .nc-cur{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten)}
|
||||
@media (max-width:720px){.net-cards{grid-template-columns:1fr}}
|
||||
|
||||
/* scaling-21 (the project lead, 7 October 2026: "the miner needs some scaling so more is visible in the initial window"): the
|
||||
Overview's vertical rhythm tightened so the rate band, the big button, the ladder strip, the chain card and the node
|
||||
card's first row sit above the fold at 1280 by 800 (fold.test.mjs measures it on build-2 at three viewports). Same
|
||||
proportions, no type below 13 px, the live scene and the GPU marks keep their look; only paddings, gaps and the
|
||||
scene's height move. The other pages already fit their first object at 1280 by 800 and stay as they are. */
|
||||
#page-overview{gap:var(--s-3)}
|
||||
#page-overview .hero-row{gap:var(--s-2)}
|
||||
#page-overview .totals{padding:16px 22px 14px}
|
||||
#page-overview .tot .k{margin-top:2px}
|
||||
#page-overview .toggle-big{min-height:54px;padding:9px 20px}
|
||||
#page-overview .ladder-strip{padding:10px 16px}
|
||||
#page-overview .ls-rungs{margin-bottom:6px}
|
||||
#page-overview .dag-card{padding:16px 18px 14px}
|
||||
#page-overview .dag-card .card-head{margin-bottom:8px}
|
||||
#page-overview #dag-live{height:184px}
|
||||
#page-overview .legend{margin-top:8px}
|
||||
#page-overview .wait-card{padding:12px 20px}
|
||||
#page-overview .node-card{padding-top:18px}
|
||||
File diff suppressed because it is too large
Load diff
52
app/igneum-app/ui/fold.test.mjs
Normal file
52
app/igneum-app/ui/fold.test.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// node --test app/igneum-app/ui/fold.test.mjs (scaling-21, the project lead, 7 October 2026: "the miner needs some scaling so
|
||||
// more is visible in the initial window"). Measures the key elements of each page inside the viewport on the mock with
|
||||
// Playwright's Chromium, at 1280 by 800, 1440 by 900 and 1920 by 1080: the Overview's rate band, big button, ladder
|
||||
// strip and chain card must end above the fold, and the node card's first row must start above it; Cards shows its
|
||||
// first card row, Earnings its headline card, Prove its switch. Runs where the Playwright env is set (build-2:
|
||||
// `. /srv/builds/_bin/overlap/env.sh`), skips elsewhere: the Mac runs no Playwright suite (CLAUDE.md, 7 October 2026).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url)), root = join(here, '../../..');
|
||||
const require = createRequire(import.meta.url);
|
||||
let chromium = null;
|
||||
try { if (process.env.IGNEUM_PLAYWRIGHT_DIR) chromium = require(join(process.env.IGNEUM_PLAYWRIGHT_DIR, 'node_modules/playwright')).chromium; } catch (e) { chromium = null; }
|
||||
const SIZES = [[1280, 800], [1440, 900], [1920, 1080]];
|
||||
const ROW = 48; // the first row of a card: its head line with the padding above it
|
||||
|
||||
test('the first screen: the Overview above the fold at 1280 by 800, 1440 by 900 and 1920 by 1080; Cards, Earnings and Prove show their first object', { skip: chromium ? false : 'no Playwright env (IGNEUM_PLAYWRIGHT_DIR); runs on build-2' }, async () => {
|
||||
const port = 4480 + Math.floor(Math.random() * 400);
|
||||
const mock = spawn(process.execPath, [join(root, 'tools/ui-mock/server.mjs'), String(port)], { stdio: 'ignore' });
|
||||
try {
|
||||
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/t/mock/api/state`); if (r.ok) break; } catch (e) { } await new Promise((r) => setTimeout(r, 250)); }
|
||||
const browser = await chromium.launch();
|
||||
const bad = [];
|
||||
const rect = (page, sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) return null; const r = el.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight }; }, sel);
|
||||
for (const [w, h] of SIZES) {
|
||||
const page = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: 'reduce', colorScheme: 'dark' });
|
||||
const open = async (scenario, p) => { await page.goto(`http://127.0.0.1:${port}/t/mock/?scenario=${scenario}&page=${p}&theme=dark`, { waitUntil: 'load' }); await page.waitForTimeout(1200); };
|
||||
const fits = async (sel, label, rowOnly) => { const r = await rect(page, sel); if (!r) { bad.push(`${w}x${h} ${label}: not found`); return; } const end = rowOnly ? r.top + ROW : r.bottom; if (!(r.top >= 0 && end <= r.inner)) bad.push(`${w}x${h} ${label}: ${rowOnly ? 'row ends at ' + end : 'ends at ' + r.bottom} of ${r.inner}`); };
|
||||
await open('live', 'overview');
|
||||
await fits('#totals', 'Overview rate band'); await fits('#btn-toggle', 'Overview big button'); await fits('#ladder-strip', 'Overview ladder strip'); await fits('#dag-card', 'Overview chain card'); await fits('#node-card', 'Overview node card first row', true);
|
||||
if (h >= 1080) await fits('#node-card', 'Overview node card whole at 1080');
|
||||
await open('rig', 'cards'); await fits('#tune-card', 'Cards tune strip'); await fits('#d-cards .gpu-line:first-child', 'Cards first card row');
|
||||
await open('ladder', 'earnings'); await fits('.earn', 'Earnings headline card');
|
||||
await open('ladder', 'prove'); await fits('#s-prove', 'Prove switch');
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
assert.deepEqual(bad, [], 'below the fold:\n' + bad.join('\n'));
|
||||
} finally { mock.kill(); }
|
||||
});
|
||||
|
||||
test('the type never drops below 13 px on the Overview density pass: no font-size under 13px inside the #page-overview rules', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const css = readFileSync(join(here, 'app.css'), 'utf8');
|
||||
const at = css.indexOf('/* scaling-21');
|
||||
assert.ok(at >= 0, 'the density block exists');
|
||||
const block = css.slice(at);
|
||||
for (const m of block.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)) assert.ok(parseFloat(m[1]) >= 13, 'font-size ' + m[1] + 'px in the density block');
|
||||
});
|
||||
150
app/igneum-app/ui/heat-region.test.mjs
Normal file
150
app/igneum-app/ui/heat-region.test.mjs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// node --test app/igneum-app/ui/heat-region.test.mjs (no dependencies; the pre-push gate runs it)
|
||||
// Ember Heat (mission item 7, docs/plans/ember-heat.md): the region prompt shows the right restricted entry for a Russian
|
||||
// region and nothing for the rest; the rent line reads the bench log's measured rate; the heat words name the duty and
|
||||
// the watts; the money symbol follows the region.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, 'app.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const V = mod.exports.View;
|
||||
|
||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||
|
||||
test('the region prompt shows the restricted entry for a Russian region, with the standing sentence first', () => {
|
||||
const line = V.restrictedLine('ru-mow');
|
||||
assert.ok(line.startsWith(V.RESTRICTED_SENTENCE), line);
|
||||
assert.equal(V.RESTRICTED_SENTENCE, 'Mining may be restricted where you are. You are responsible for checking.');
|
||||
assert.ok(line.includes('Moscow region from 15 August 2026 to 2032'), line);
|
||||
assert.ok(V.restrictedLine('ru').includes('ten regions from 1 January 2025 to 15 March 2031'), 'the other-region entry carries the ten-region ban');
|
||||
assert.ok(V.restrictedLine('ru').includes('does not carry the list of ten'), 'the app says what it does not know');
|
||||
for (const seasonal of ['ru-irk', 'ru-bur', 'ru-zab']) assert.ok(V.restrictedLine(seasonal).includes('seasonal mining ban'), seasonal);
|
||||
assert.ok(V.restrictedLine('cn').includes('illegal in China'), 'China is the other entry of future.md 8.3 item 7');
|
||||
});
|
||||
|
||||
test('no region outside the restricted list of future.md 8.3 gets a line', () => {
|
||||
const restricted = V.REGIONS.filter((r) => r.restricted).map((r) => r.code).sort();
|
||||
assert.deepEqual(restricted, ['cn', 'ru', 'ru-bur', 'ru-irk', 'ru-mow', 'ru-zab'], 'Russia (the ten regions, Moscow, the seasonal three) and China, nothing else');
|
||||
for (const code of ['gb', 'de', 'us', 'kz', 'py', 'ir', 'other', '']) assert.equal(V.restrictedLine(code), '', code);
|
||||
});
|
||||
|
||||
// the research file and the bench log are internal (tools/ci/export-exclude.txt): a tree without them skips the read
|
||||
const research = join(here, '../../../docs/analysis/mission/future.md');
|
||||
const benchLog = join(here, '../../../docs/bench-log.md');
|
||||
|
||||
test('the restricted entries are the ones the research file states (future.md section 4.4 and 8.3)', (t) => {
|
||||
if (!existsSync(research)) { t.skip('docs/analysis/mission/future.md is not in this tree'); return; }
|
||||
const future = readFileSync(research, 'utf8');
|
||||
assert.ok(future.includes('mining banned in 10 regions 1 Jan 2025 to 15 Mar 2031'), 'the ten-region ban and its dates');
|
||||
assert.ok(future.includes('Moscow region from 15 Aug 2026 to 2032'), 'the Moscow region entry and its dates');
|
||||
assert.ok(future.includes('seasonal bans in Irkutsk, Buryatia, Zabaikalsky'), 'the seasonal three');
|
||||
assert.ok(/China \| illegal; joint Notice 6 Feb 2026/.test(future), 'China');
|
||||
assert.ok(future.includes('"mining may be restricted where you are; you are responsible for checking"'), 'the standing sentence');
|
||||
});
|
||||
|
||||
test('the price prompt defaults from the public table and says it is typed, never fetched', () => {
|
||||
assert.equal(V.regionOf('gb').price, 26.32);
|
||||
assert.ok(V.regionOf('gb').source.includes('Ofgem'));
|
||||
assert.equal(V.regionOf('de').price, 38.69);
|
||||
assert.equal(V.regionOf('us').price, 17.7);
|
||||
assert.equal(V.regionOf('ru-mow').price, 0, 'no table price for a Russian region: the miner types one');
|
||||
assert.ok(V.priceNote('gb').includes('Typical: 26.32 p per kWh'));
|
||||
assert.ok(V.priceNote('gb').endsWith('Nothing is fetched.'));
|
||||
assert.ok(V.priceNote('ru').startsWith('No table price for this region.'));
|
||||
assert.ok(V.priceNote('').includes('Nothing is fetched.'));
|
||||
assert.ok(!src.includes('fetch(\'https://') && !src.includes('exchangerate'), 'the UI fetches no price and no rate');
|
||||
});
|
||||
|
||||
test('the money symbol follows the region: pounds, euros, dollars', () => {
|
||||
V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50');
|
||||
V.setRegion('de'); assert.equal(V.money(1.5), '€1.50'); assert.equal(V.currencyOf('de').minor, 'c');
|
||||
V.setRegion('us'); assert.equal(V.money(1.5), '$1.50');
|
||||
V.setRegion('ru-mow'); assert.equal(V.currencyOf('ru-mow').code, 'USD', 'a region without a table currency types US cents');
|
||||
V.setRegion(''); assert.equal(V.money(1.5), '£1.50', 'no region chosen: pounds, as before');
|
||||
});
|
||||
|
||||
test('the rent line reads the bench log: the measured USD per MH/s-hour in the app is the bench log\'s number', (t) => {
|
||||
if (!existsSync(benchLog)) { t.skip('docs/bench-log.md is not in this tree'); return; }
|
||||
const log = readFileSync(benchLog, 'utf8');
|
||||
const section = log.slice(log.lastIndexOf('## Rental cost of hash'));
|
||||
assert.ok(section.length > 0, 'the bench log carries a Rental cost of hash entry');
|
||||
const m = section.match(/USD ([0-9.]+) per MH\/s-hour/);
|
||||
assert.ok(m, 'the entry states USD x per MH/s-hour');
|
||||
assert.equal(V.RENT.usd_per_mhs_hour, parseFloat(m[1]), 'the app carries the bench log\'s measured rate; update RENT when the log moves');
|
||||
const date = section.match(/## Rental cost of hash, ([0-9]+ [A-Za-z]+ [0-9]{4})/);
|
||||
assert.ok(date, 'the entry is dated');
|
||||
assert.equal(V.RENT.measured, date[1]);
|
||||
});
|
||||
|
||||
test('the cost-against-rent line: a UK card at the Ofgem price is about 10x cheaper than rented hash (future.md 3.4)', () => {
|
||||
V.setRegion('gb');
|
||||
// 3.27 W per MH/s at 26.32 p: 0.0861 p per MH/s-hour, about USD 0.00114, 10x under 0.0117
|
||||
const r = V.rentLine([card({ power_w: 327, hash_now: 100 })], 26.32, 'gb');
|
||||
assert.equal(r.kind, 'line');
|
||||
assert.ok(Math.abs(r.cost - 0.0861) < 0.001, r.cost);
|
||||
assert.ok(Math.abs(r.usd - 0.00114) < 0.0001, r.usd);
|
||||
assert.equal(r.text, '0.086 p per MH/s-hour');
|
||||
assert.ok(r.ratio > 9.5 && r.ratio < 11, r.ratio);
|
||||
assert.ok(r.sub.includes('Rented hash: USD 0.0117 per MH/s-hour'), r.sub);
|
||||
assert.ok(r.sub.includes('6 October 2026'), r.sub);
|
||||
assert.ok(r.verdict.startsWith('Yours is 10'), r.verdict);
|
||||
assert.ok(r.sub.includes('about USD 0.0011'), r.sub);
|
||||
});
|
||||
|
||||
test('the cost-against-rent line says when rented hash undercuts the card, and what it needs when it cannot say', () => {
|
||||
// a 3080-class card at 12 GB and a dear tariff: 6 W per MH/s at 60 c (euro) = 0.36 c = USD 0.0039: still under the rent
|
||||
const under = V.rentLine([card({ power_w: 300, hash_now: 50 })], 60, 'de');
|
||||
assert.equal(under.kind, 'line');
|
||||
assert.ok(under.ratio > 2.5 && under.ratio < 3.5, under.ratio);
|
||||
// the day idle datacentre hash sets the rent at 0.00016 the line flips (the table in future.md 3.4): here, a card at
|
||||
// 20 W per MH/s on 100 c power = 2 c = USD 0.0216, dearer than the rent
|
||||
const dear = V.rentLine([card({ power_w: 400, hash_now: 20 })], 100, 'us');
|
||||
assert.equal(dear.kind, 'line');
|
||||
assert.ok(dear.ratio < 1, dear.ratio);
|
||||
assert.ok(dear.verdict.startsWith('Rented hash undercuts your card by'), dear.verdict);
|
||||
assert.equal(dear.sub.includes('about USD'), false, 'a dollar region needs no conversion');
|
||||
// no price: the W per MH/s and the ask
|
||||
const ask = V.rentLine([card()], 0, 'gb');
|
||||
assert.equal(ask.kind, 'price');
|
||||
assert.ok(ask.sub.includes('W per MH/s'), ask.sub);
|
||||
// not mining and never tuned: nothing to say, the rent still stated
|
||||
const none = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0 })], 26.32, 'gb');
|
||||
assert.equal(none.kind, 'none');
|
||||
assert.ok(none.sub.includes('USD 0.0117'));
|
||||
// not mining but tuned: the tune's point stands in, and the line says so
|
||||
const tuned = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0, sweep_watts: 290, sweep_mhs: 122.3 })], 26.32, 'gb');
|
||||
assert.equal(tuned.kind, 'line');
|
||||
assert.ok(tuned.sub.includes('(the last tune)'), tuned.sub);
|
||||
});
|
||||
|
||||
test('the heat words: the strip line names the set point, the room, the share of the hour and the watts', () => {
|
||||
const heating = { on: true, phase: 'heating', set_c: 20, room_c: 19.6, room_source: 'typed', duty: 0.6, duty_hour: 0.55, heat_w: 410, full_w: 410, until_s: 300 };
|
||||
assert.deepEqual(V.heatLine(heating), { text: 'Heat mode: holding 20.0 °C · room 19.6 °C · heating 55% of the time · 410 W of heat', tone: 'heat' });
|
||||
const resting = { ...heating, phase: 'resting', heat_w: 0, room_source: 'card', room_c: 19.2 };
|
||||
assert.deepEqual(V.heatLine(resting), { text: 'Heat mode: holding 20.0 °C · room about 19 °C · heating 55% of the time · resting, 410 W when heating', tone: 'rest' });
|
||||
assert.equal(V.heatLine({ on: false }).text, '');
|
||||
assert.ok(V.heatLine({ ...heating, room_source: 'none', duty_hour: 0 }).text.includes('no room reading yet · heating 60% of the time'));
|
||||
assert.equal(V.heatRowWords(heating), 'heat 55% · 410 W of heat');
|
||||
assert.equal(V.heatRowWords(resting), 'heat 55% · resting');
|
||||
assert.equal(V.heatRowWords({ on: false }), '');
|
||||
});
|
||||
|
||||
test('a resting card and the big button say heat mode, never a bare off', () => {
|
||||
const r = V.cardRow(card({ state: 'resting', hash_now: 0, message: 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' }));
|
||||
assert.equal(r.word, 'resting (heat mode)');
|
||||
assert.equal(r.sub, 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)', 'the engine\'s line is the row\'s second line');
|
||||
assert.equal(r.hash, '');
|
||||
const m = { state: 'waiting', paused: false, cards: [card({ state: 'resting', hash_now: 0 })], found: [] };
|
||||
const t = V.toggle(m, { synced: true }, { heat: { on: true, phase: 'resting', set_c: 20, until_s: 240 } });
|
||||
assert.equal(t.label, 'Stop mining');
|
||||
assert.equal(t.sub, 'resting · heat mode holds 20.0 °C, heats again in 4 min');
|
||||
assert.equal(t.act, 'pause');
|
||||
assert.deepEqual(V.pill({ setup_done: true, mining: m, node: { state: 'synced' }, heat: { on: true, phase: 'resting' } }), { text: 'Resting · heat mode', tone: '' });
|
||||
const e = V.ember(card({ tune_before_watts: 0 }), { settings: { sweep: true }, heat: { on: true, phase: 'heating', duty: 0.5, duty_hour: 0.5, heat_w: 410 } }, 1);
|
||||
assert.equal(e.heat, 'heat 50% · 410 W of heat');
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="mine">
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="overview">
|
||||
|
||||
<!-- the rail: four sections (miner-ui-3: Mine, Earnings, Prove, Settings), Logs and Quit in the foot. Under 720 px it
|
||||
is a bottom tab bar. The setup screens have no rail. -->
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
<span class="word">IGNEUM</span>
|
||||
</div>
|
||||
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
||||
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
|
||||
<button class="nav on" data-page="overview" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Overview</span></button>
|
||||
<button class="nav" data-page="cards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="11" rx="2"/><path d="M7 20h10M9 16v4M15 16v4M7 10h3M14 10h3"/></svg><span>Cards</span></button>
|
||||
<button class="nav" data-page="earnings"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Earnings</span></button>
|
||||
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</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>
|
||||
|
|
@ -37,7 +38,7 @@
|
|||
<span class="word">IGNEUM</span><span class="miner">MINER</span>
|
||||
</div>
|
||||
<div class="page-title" id="page-title" hidden>
|
||||
<h1 id="page-title-text">Mine</h1>
|
||||
<h1 id="page-title-text">Overview</h1>
|
||||
<span class="page-sub" id="page-sub"></span>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
|
|
@ -48,8 +49,10 @@
|
|||
<!-- the status strip: one notice at a time (app.js, Notices) -->
|
||||
<div class="notices" id="notices" hidden>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">
|
||||
<i class="notice-dot" aria-hidden="true"></i>
|
||||
<span class="notice-text" id="notice-text"></span>
|
||||
<span class="notice-actions" id="notice-actions"></span>
|
||||
<button class="notice-more" id="notice-more" data-act="more" title="The technical line" aria-label="Show the technical line" aria-expanded="false" hidden><span class="chev"></span></button>
|
||||
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">×</button>
|
||||
<span class="notice-detail mono" id="notice-detail" hidden></span>
|
||||
<span class="prog" id="notice-prog" hidden><i></i></span>
|
||||
|
|
@ -101,7 +104,7 @@
|
|||
<!-- 2. cards -->
|
||||
<section class="screen" id="screen-cards">
|
||||
<div class="step">
|
||||
<div class="eyebrow">step 1 of 2</div>
|
||||
<div class="eyebrow">step 1 of 4</div>
|
||||
<h2>Your graphics card</h2>
|
||||
<p class="sub" id="cards-sub">Asking the graphics cards to report in.</p>
|
||||
<div class="cards" id="cards-list">
|
||||
|
|
@ -115,7 +118,7 @@
|
|||
</div>
|
||||
<p class="note" id="cards-note" hidden></p>
|
||||
<p class="note" id="cards-power" hidden>NVIDIA cards start at 80% of their power limit, which keeps them stable. Windows asks for administrator rights once for that. The card row has a slider.</p>
|
||||
<p class="note" id="cards-help" hidden>An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
|
||||
<p class="note" id="cards-help" hidden>A built-in GPU starts off. It is slow and shares the machine's memory.</p>
|
||||
<div class="cta">
|
||||
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
|
||||
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
|
||||
|
|
@ -123,10 +126,57 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. address -->
|
||||
<!-- 3. region and price (Ember Heat, mission item 7): the region list, a typed price never fetched, the restricted line -->
|
||||
<section class="screen" id="screen-region">
|
||||
<div class="step">
|
||||
<div class="eyebrow">step 2 of 4</div>
|
||||
<h2>Your electricity</h2>
|
||||
<p class="sub">Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.</p>
|
||||
<div class="field">
|
||||
<div class="k">region</div>
|
||||
<select class="addr-input select" id="region-select" aria-label="Region"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">currency</div>
|
||||
<select class="addr-input select" id="region-currency" aria-label="Currency"></select>
|
||||
<p class="help ask-cur" id="region-currency-ask" hidden></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">price per kWh</div>
|
||||
<div class="row">
|
||||
<input type="number" class="addr-input mono short" id="region-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="region-unit">pence per kWh</span>
|
||||
</div>
|
||||
<p class="help" id="region-note">Choose a region to see a typical price.</p>
|
||||
</div>
|
||||
<p class="restricted" id="region-restricted" hidden></p>
|
||||
<div class="cta">
|
||||
<button class="btn primary" id="btn-region-next">Continue</button>
|
||||
<button class="btn ghost" id="btn-region-back">Back</button>
|
||||
<button class="btn ghost" id="btn-region-skip">Skip for now</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. the network (0.3.23): two cards; the fresh-install default comes from the manifest's default_network, the testnet
|
||||
card reads "not yet open" and is refused until the manifest names it open -->
|
||||
<section class="screen" id="screen-network">
|
||||
<div class="step">
|
||||
<div class="eyebrow">step 3 of 4</div>
|
||||
<h2>Which network</h2>
|
||||
<p class="sub">The chain this machine mines. You can change it later in Settings; a change takes effect at the next start.</p>
|
||||
<div class="net-cards" id="network-cards" role="radiogroup" aria-label="Network"></div>
|
||||
<p class="help ask-cur" id="network-note" hidden></p>
|
||||
<div class="cta">
|
||||
<button class="btn primary" id="btn-network-next">Continue</button>
|
||||
<button class="btn ghost" id="btn-network-back">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. address -->
|
||||
<section class="screen" id="screen-address">
|
||||
<div class="step">
|
||||
<div class="eyebrow">step 2 of 2</div>
|
||||
<div class="eyebrow">step 4 of 4</div>
|
||||
<h2>Where should rewards go?</h2>
|
||||
<p class="sub">Every block this machine finds pays one address. Pick one way.</p>
|
||||
<div class="options">
|
||||
|
|
@ -143,7 +193,7 @@
|
|||
<span class="radio"></span>
|
||||
<span class="body">
|
||||
<span class="t">Use my own address</span>
|
||||
<span class="s">Paste an Ethereum-style address you control: 0x and 40 characters.</span>
|
||||
<span class="s">Paste an address you control. It starts with 0x and has 40 characters after it.</span>
|
||||
<input type="text" class="addr-input mono" id="addr-input" placeholder="0x" spellcheck="false" autocomplete="off">
|
||||
<span class="err" id="addr-err" hidden>That is not an address. 0x followed by 40 hex characters.</span>
|
||||
</span>
|
||||
|
|
@ -157,11 +207,11 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. the dashboard: four pages behind the rail -->
|
||||
<!-- 5. the dashboard: four pages behind the rail -->
|
||||
<section class="screen" id="screen-dashboard">
|
||||
|
||||
<!-- Mine -->
|
||||
<section class="page" id="page-mine" data-page="mine">
|
||||
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
|
||||
<section class="page" id="page-overview" data-page="overview">
|
||||
<div class="clock-card" id="m-clock" hidden>
|
||||
<p class="clock-msg" id="m-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="m-clock-sync">Sync clock</button><span class="note mono small" id="m-clock-result"></span></div>
|
||||
|
|
@ -182,24 +232,73 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span><button class="btn small" id="btn-tune-all" hidden>Tune all</button></div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
<!-- miner-ui-5: before the first block, the Poisson count-up; on a milestone, the block card (in place, never a dialog) -->
|
||||
<div class="card wait-card" id="first-wait" hidden>
|
||||
<div class="wait-row"><span class="wait-ring" aria-hidden="true"><svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15.5"></circle><circle class="arc" id="first-wait-arc" cx="18" cy="18" r="15.5"></circle></svg><span class="pct mono" id="first-wait-pct">0%</span></span>
|
||||
<div class="wait-text"><div class="t" id="first-wait-line">Waiting for your first block.</div><div class="s" id="first-wait-sub"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card block-card" id="block-card" hidden>
|
||||
<div class="bc-head"><span class="eyebrow ember" id="bc-eyebrow">your first block</span><span class="bc-when mono" id="bc-when"></span><button class="notice-close bc-close" id="bc-close" title="Dismiss" aria-label="Dismiss the block card">×</button></div>
|
||||
<h2 class="bc-title" id="bc-title">Block 0 is yours.</h2>
|
||||
<p class="bc-line" id="bc-card"></p>
|
||||
<p class="bc-line" id="bc-ign"></p>
|
||||
<p class="bc-line dim" id="bc-rank"></p>
|
||||
<p class="bc-hash mono" id="bc-hash"></p>
|
||||
<div class="row wrap"><button class="btn small primary" id="bc-open">Open in the explorer</button><button class="btn small" id="bc-save">Save the card</button><button class="btn small ghost" id="bc-copy">Copy the link</button><span class="note mono small" id="bc-saved"></span></div>
|
||||
<canvas id="bc-canvas" width="1200" height="630" hidden aria-hidden="true"></canvas>
|
||||
</div>
|
||||
<div class="card ladder-strip" id="ladder-strip" hidden>
|
||||
<div class="ls-rungs" id="ls-rungs" role="list" aria-label="The ladder"></div>
|
||||
<div class="ls-line"><span class="t" id="ls-line"></span><span class="s dim" id="ls-sub"></span><button class="btn tiny ghost" id="ls-more">The ladder</button></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tune-card">
|
||||
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="tune-eyebrow">Ember Tune</span></div>
|
||||
<div class="goal-row">
|
||||
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line"></span>
|
||||
<div class="card dag-card" id="dag-card">
|
||||
<div class="card-head"><h3>The chain, live</h3><div class="head-right"><span class="eyebrow" id="dag-state">connecting</span><button class="btn tiny ghost" id="dag-inspect" aria-expanded="false" aria-controls="chain-full">Inspect</button><button class="btn tiny ghost" id="dag-pause" aria-pressed="false">Pause</button></div></div>
|
||||
<!-- the compact scene (EMBER 02 live-dag.js, the site lane's module, byte-identical): the app feeds it its own api/live reply -->
|
||||
<div class="dag-wrap">
|
||||
<canvas id="dag-live" aria-label="The last minute of blocks: your lane and the other miner keys, the selected chain as one path, checkpoints as bands, your blocks framed"></canvas>
|
||||
<div class="dag-tip mono" id="dag-tip" hidden></div>
|
||||
<span class="dag-chip mono" id="dag-chip" hidden>scroll to zoom · Esc to release</span>
|
||||
<canvas id="dag" aria-hidden="true" hidden></canvas>
|
||||
</div>
|
||||
<div class="legend" data-legend="mine"><span class="dim" id="dag-note"></span></div>
|
||||
<!-- the full scene with the block inspector (the words of the site's /live): opened by Inspect or by a click on a block -->
|
||||
<div class="chain-full" id="chain-full" hidden>
|
||||
<div class="cf-bar">
|
||||
<div class="seg small" id="dag-filter" role="radiogroup" aria-label="Which blocks"><button class="seg-b on" data-filter="all" role="radio" aria-checked="true">All blocks</button><button class="seg-b" data-filter="chain" role="radio" aria-checked="false">Selected chain</button><button class="seg-b" data-filter="mine" role="radio" aria-checked="false">Your blocks</button></div>
|
||||
<span class="cf-right"><span class="zoom"><button type="button" id="z-out" aria-label="Wider window">−</button><span id="z-pct" class="mono">60 s</span><button type="button" id="z-in" aria-label="Narrower window">+</button></span><button class="btn tiny ghost on" id="dag-follow">Follow</button><button class="btn tiny ghost" id="dag-pause-full" aria-pressed="false">Pause</button></span>
|
||||
</div>
|
||||
<div class="cf-graph">
|
||||
<div class="cf-scene">
|
||||
<canvas id="dag-full" aria-label="The devnet's block graph, live: time-aligned miner lanes, every parent connection, the selected chain as one path, checkpoint bands, your blocks framed"></canvas>
|
||||
<div class="dag-tip mono" id="dag-tip-full" hidden></div>
|
||||
<span class="dag-chip mono" id="dag-chip-full" hidden>scroll to zoom · Esc to release</span>
|
||||
<div class="cf-foot mono">Click to inspect · Drag to explore</div>
|
||||
</div>
|
||||
<aside class="inspector" id="inspector" aria-live="polite">
|
||||
<div class="ih"><span class="eyebrow">Block inspector</span><span class="chip mono" id="i-kind">select a block</span></div>
|
||||
<h3 class="ititle" id="i-title">Select a block</h3>
|
||||
<div class="ihash mono" id="i-hash">Click the graph.</div>
|
||||
<div class="proof-scene"><canvas id="proof" aria-label="Select a block to see its shards"></canvas></div>
|
||||
<div class="proof-head"><span>Proof shards</span><span id="i-shard-count" class="mono">not set</span></div>
|
||||
<div class="track" id="i-track"></div>
|
||||
<ul class="shards mono" id="i-shards"><li class="muted">No block selected.</li></ul>
|
||||
<dl class="insp mono">
|
||||
<dt>Inclusion</dt><dd id="i-incl">not set</dd>
|
||||
<dt>Miner key</dt><dd id="i-miner">not set</dd>
|
||||
<dt>Blue score</dt><dd id="i-blue">not set</dd>
|
||||
<dt>DAA score</dt><dd id="i-daa">not set</dd>
|
||||
<dt>Parent links</dt><dd id="i-parents">not set</dd>
|
||||
<dt>Header time</dt><dd id="i-time">not set</dd>
|
||||
<dt>Checkpoint</dt><dd id="i-cp">not set</dd>
|
||||
<dt>Finality</dt><dd id="i-lock">not set</dd>
|
||||
</dl>
|
||||
<p class="note">Proof, chain selection and finality are separate states. None is inferred from a block’s age; a lock is drawn only when the observer reports one.</p>
|
||||
<p class="top-gap"><button class="btn tiny ghost" id="insp-close" hidden>Clear</button></p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<p class="line-text" id="tune-schedule"></p>
|
||||
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
||||
</div>
|
||||
|
||||
<div class="card node-card" id="node-card">
|
||||
|
|
@ -210,25 +309,26 @@
|
|||
<div class="details" id="node-details" hidden>
|
||||
<div class="kv">
|
||||
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
|
||||
<div><span class="k">whose node</span><span class="v" id="n-source"></span><span class="m" id="n-source-m"></span></div>
|
||||
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
|
||||
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
|
||||
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>
|
||||
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span><span class="m">headers arrive before blocks</span></div>
|
||||
<div><span class="k">DAA score</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made</span></div>
|
||||
<div><span class="k">network blocks</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made (the DAA score)</span></div>
|
||||
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span><span class="m">how hard the next block is to find</span></div>
|
||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the block DAG right now</span></div>
|
||||
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain</span></div>
|
||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the chain right now</span></div>
|
||||
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain (the blue score)</span></div>
|
||||
<div><span class="k">next program</span><span class="v mono" id="d-eta">--:--</span><span class="m" id="d-eta-sub">the hourly mining program</span></div>
|
||||
<div><span class="k">last lock</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
|
||||
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s</span></div>
|
||||
<div><span class="k">last checkpoint</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
|
||||
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s (a point the miners agree can never be undone)</span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">rules digest</div>
|
||||
<div class="k">rules fingerprint</div>
|
||||
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
|
||||
<p class="help">Every node on the network shows the same fingerprint of the rules. A peer with another one is refused.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">next rule switch</div>
|
||||
<div class="k">next rule change</div>
|
||||
<div class="line-text" id="n-switch">none planned</div>
|
||||
<p class="help" id="n-switch-help"></p>
|
||||
<div class="switch-list mono" id="n-switches"></div>
|
||||
|
|
@ -238,22 +338,66 @@
|
|||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Activity</h3><div class="head-right"><span class="stats mono" id="d-stats"></span><button class="btn small ghost" id="btn-open-log">Open the log</button></div></div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards: every card with Ember as its second layer -->
|
||||
<section class="page" id="page-cards" data-page="cards" hidden>
|
||||
<div class="card tune-strip" id="tune-card">
|
||||
<div class="card-head"><h3>Ember Tune</h3><span class="eyebrow">tunes every card for hashes per watt</span></div>
|
||||
<div class="ts-row">
|
||||
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line"></span>
|
||||
<span class="ts-saving" id="fleet-saving"></span>
|
||||
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
|
||||
</div>
|
||||
<p class="line-text" id="tune-schedule"></p>
|
||||
<p class="line-text heat-line" id="heat-line" hidden></p>
|
||||
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span></div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Earnings -->
|
||||
<section class="page" id="page-earnings" data-page="earnings" hidden>
|
||||
<div class="card">
|
||||
<div class="money">
|
||||
<div class="money-row"><span class="v" id="e-earned">£0.00</span><span class="s" id="e-earned-sub">earned: nothing is bought or sold on devnet</span></div>
|
||||
<div class="money-row"><span class="v small" id="e-ign">0.0000 IGN</span><span class="s" id="e-ign-sub">from 0 proofs</span></div>
|
||||
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span></div>
|
||||
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
|
||||
<!-- earnings-tidy (7 October 2026): the day rate first with its reason, the run line, then a quiet row of three
|
||||
(weight, electricity, lifetime), the dev-fee switch last. IGN only: no typed price anywhere. Every number names
|
||||
its source on hover (title). -->
|
||||
<div class="earn">
|
||||
<div class="earn-head"><span class="v" id="e-day">0 IGN a day</span><span class="s" id="e-day-sub">reading the network</span></div>
|
||||
<div class="earn-run"><span class="v" id="e-run">0 blocks this run</span><span class="s" id="e-run-sub"></span></div>
|
||||
<div class="earn-three">
|
||||
<div class="earn-cell"><span class="k">Weight</span><span class="v" id="e-rung">reading</span><span class="s" id="e-rung-sub"></span></div>
|
||||
<div class="earn-cell"><span class="k">Electricity</span><span class="v" id="e-cost">no draw</span><span class="s" id="e-cost-sub"></span></div>
|
||||
<div class="earn-cell"><span class="k">Lifetime</span><span class="v" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the people who make this app.</span></label>
|
||||
</div>
|
||||
|
||||
<div class="card" id="ladder-card">
|
||||
<div class="card-head"><h3>The ladder</h3><div class="head-right"><span class="eyebrow" id="ladder-source">reading</span></div></div>
|
||||
<p class="help">Weight is your blocks over the window, written into consensus. No points server, no badge: the chain computes every rung and your node reports it. Hover a line for the field it reads.</p>
|
||||
<ol class="rungs" id="rungs"></ol>
|
||||
</div>
|
||||
|
||||
<div class="card" id="timeline-card">
|
||||
<button class="disclose" id="tl-toggle" aria-expanded="false" aria-controls="tl-details"><span>Your first hour</span><span class="disclose-right"><span class="dim" id="tl-sum"></span><span class="chev" aria-hidden="true"></span></span></button>
|
||||
<div class="details" id="tl-details" hidden>
|
||||
<ol class="timeline" id="tl-steps"></ol>
|
||||
<p class="note" id="tl-note"></p>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the miner software's author</span></label>
|
||||
<p class="help" id="r-devfee-line"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
|
@ -293,23 +437,36 @@
|
|||
|
||||
<!-- Prove -->
|
||||
<section class="page" id="page-prove" data-page="prove" hidden>
|
||||
<div class="card shard-card" id="shard-card" hidden>
|
||||
<div class="bc-head"><span class="eyebrow ember">proof shards</span><span class="bc-when mono" id="sc-when"></span></div>
|
||||
<h2 class="bc-title small" id="sc-title">Your card proved shard 0 of block 0.</h2>
|
||||
<p class="bc-line" id="sc-line"></p>
|
||||
<p class="bc-line dim" id="sc-sub"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3>Prove on this machine</h3>
|
||||
<p class="help">Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN.</p>
|
||||
<p class="help">Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one.</p>
|
||||
</div>
|
||||
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
|
||||
<label class="switch big" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
|
||||
</div>
|
||||
<div class="row" id="pv-instead-row" hidden>
|
||||
<div>
|
||||
<h3>Prove instead of mining</h3>
|
||||
<p class="help" id="pv-instead-help">A card under 12 GB holds the prover or the miner, never both. On, the miner stops on that card while it proves and comes back when proving stops.</p>
|
||||
</div>
|
||||
<label class="switch big" title="Prove instead of mining"><input type="checkbox" id="s-prove-instead" aria-label="Prove instead of mining"><span class="track"></span></label>
|
||||
</div>
|
||||
<ul class="tier-list" id="pv-tiers"></ul>
|
||||
<p class="line-text" id="pv-line"></p>
|
||||
<p class="note" id="pv-note"></p>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">Takes about 20 minutes, once.</span></div>
|
||||
<button class="disclose" id="pv-toggle" aria-expanded="false" aria-controls="pv-details"><span>Details</span><span class="chev" aria-hidden="true"></span></button>
|
||||
<div class="details" id="pv-details" hidden>
|
||||
<div class="kv">
|
||||
<div><span class="k">verifier</span><span class="v" id="pv-verifier">not read yet</span><span class="m" id="pv-verifier-help">the node checks a proof before it counts</span></div>
|
||||
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments this machine proved</span></div>
|
||||
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments (runs of 8 blocks) this machine proved</span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">shard program id</div>
|
||||
|
|
@ -318,7 +475,7 @@
|
|||
<div class="field">
|
||||
<div class="k">aggregator id</div>
|
||||
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
|
||||
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
|
||||
<p class="help">Every proof names the program that made it: the shard program for one shard, the aggregator for a whole segment. Other nodes accept a proof only from these two ids.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -330,30 +487,80 @@
|
|||
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="s-tune-eyebrow">Ember Tune</span></div>
|
||||
<div class="goal-row">
|
||||
<div class="seg" id="goal-seg-2" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line-2"></span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are measured only.</span><span class="dim" id="s-power-note"></span></span></label>
|
||||
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Hill climb</b> <span class="dim">Searches around the best point instead of walking the ladders.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are only measured.</span><span class="dim" id="s-power-note"></span></span></label>
|
||||
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span></span></label>
|
||||
<p class="line-text" id="s-tune-schedule"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Electricity</h3><span class="eyebrow">typed, never fetched</span></div>
|
||||
<div class="field">
|
||||
<div class="k">electricity price</div>
|
||||
<div class="k">region</div>
|
||||
<select class="addr-input select" id="s-region" aria-label="Region"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">currency</div>
|
||||
<select class="addr-input select" id="s-currency" aria-label="Currency"></select>
|
||||
<p class="help ask-cur" id="s-currency-ask" hidden></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">price per kWh</div>
|
||||
<div class="row">
|
||||
<input type="number" class="addr-input mono short" id="s-price" min="0" max="200" step="0.1" placeholder="28" aria-label="Electricity price in pence per kWh"><span class="note">pence per kWh. Every £ figure uses it.</span>
|
||||
<input type="number" class="addr-input mono short" id="s-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="s-price-unit">pence per kWh</span>
|
||||
<button class="btn small" id="s-price-save">Save</button>
|
||||
</div>
|
||||
<p class="help" id="s-price-note"></p>
|
||||
</div>
|
||||
<p class="restricted" id="s-region-restricted" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Heat mode</h3><span class="eyebrow">Ember Heat</span></div>
|
||||
<label class="switch"><input type="checkbox" id="s-heat"><span class="track"></span><span class="sw-text"><b>Hold a room temperature</b> <span class="dim">The cards heat for a share of every 10 minutes and rest for the rest. The hash follows. In heat mode your card is an electric heater that also earns; whether it beats your boiler depends on the network and the price, shown live.</span></span></label>
|
||||
<p class="line-text" id="s-heat-line"></p>
|
||||
<div class="row wrap">
|
||||
<div class="field">
|
||||
<div class="k">hold</div>
|
||||
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-set" min="5" max="30" step="0.5" placeholder="19" aria-label="Set point in degrees"><span class="note">°C</span></div>
|
||||
</div>
|
||||
<div class="field grow">
|
||||
<div class="k">schedule</div>
|
||||
<div class="row"><input type="text" class="addr-input mono" id="s-heat-schedule" placeholder="06:00 20, 22:00 16" spellcheck="false" autocomplete="off" aria-label="Schedule"><button class="btn small" id="s-heat-save">Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">Blank holds one temperature all day. A schedule is a list of times and temperatures; each holds until the next. Times are this window's clock.</p>
|
||||
<div class="field">
|
||||
<div class="k">room now</div>
|
||||
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-room" min="-20" max="50" step="0.1" placeholder="19.5" aria-label="Room temperature now"><span class="note">°C from your own thermometer</span><button class="btn small" id="s-heat-room-save">Use it</button></div>
|
||||
<p class="help" id="s-heat-room-note">A reading you type is the room for two hours. Without one the card's own sensor reads the room after 3 minutes of rest, within about 3 degrees. Typing a reading while the cards rest teaches the app the card's idle offset.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Network</h3><span class="eyebrow" id="s-network-eyebrow"></span></div>
|
||||
<p class="help" id="s-network-line"></p>
|
||||
<div class="net-cards" id="s-network-cards" role="radiogroup" aria-label="Network"></div>
|
||||
<p class="help ask-cur" id="s-network-note" hidden></p>
|
||||
<div class="ask inline" id="ask-network" hidden>
|
||||
<span class="ask-text" id="ask-network-text"></span>
|
||||
<button class="btn small primary" id="ask-network-yes">Switch</button>
|
||||
<button class="btn small ghost" id="ask-network-no">Keep</button>
|
||||
</div>
|
||||
<p class="line-text" id="s-tune-schedule"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>This machine</h3></div>
|
||||
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
|
||||
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check now</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
|
||||
<label class="switch"><input type="checkbox" id="s-profile"><span class="track"></span><span class="sw-text"><b>Make my page public</b> <span class="dim" id="s-profile-text">Off: igneum.network/address keeps your page unlisted. Nothing about this machine is published.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs from the team (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
|
||||
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check for jobs</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
|
||||
<div class="details indent" id="s-jobs-history" hidden></div>
|
||||
<p class="note mono small indent" id="s-jobs-key" hidden></p>
|
||||
<div class="field">
|
||||
|
|
@ -362,7 +569,7 @@
|
|||
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
|
||||
<button class="btn small" id="s-name-save">Rename</button>
|
||||
</div>
|
||||
<p class="help">A label for you only.</p>
|
||||
<p class="help">A name for you only.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">appearance</div>
|
||||
|
|
@ -374,6 +581,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="s-interface-card">
|
||||
<div class="card-head"><h3>Interface</h3><span class="eyebrow" id="s-ui-eyebrow">built in</span></div>
|
||||
<p class="line-text" id="s-ui-line">Interface 1.0.0, built in</p>
|
||||
<p class="help" id="s-ui-help">Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-ui-builtin"><span class="track"></span><span class="sw-text"><b>Use the built-in interface</b> <span class="dim">Serves the interface this version of the app was built with, even when a newer one has arrived over the air.</span></span></label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
|
|
@ -382,7 +596,7 @@
|
|||
</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>
|
||||
<button class="btn small" id="s-update">Check for an update</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-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 at a quiet moment, never mid-program.</span></span></label>
|
||||
|
|
@ -401,8 +615,8 @@
|
|||
|
||||
<details class="card adv" id="s-advanced">
|
||||
<summary><h3>Advanced</h3><span class="eyebrow">devnet</span></summary>
|
||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on finality checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes lock the chain; leave it on.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proof records without verifying them</b> <span class="dim">Devnet only. Without a verifier the node includes records it never checked. Changing this restarts the node.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes are what lock the chain. Leave it on.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proofs without checking them</b> <span class="dim">Devnet only. The node includes proofs it never checked. Changing this restarts the node.</span></span></label>
|
||||
<div class="ask inline" id="ask-trust" hidden>
|
||||
<span class="ask-text" id="ask-trust-text"></span>
|
||||
<button class="btn small primary" id="ask-trust-yes">Confirm</button>
|
||||
|
|
@ -494,6 +708,8 @@
|
|||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="live-dag.js"></script>
|
||||
<script src="proof-core.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
426
app/igneum-app/ui/live-dag.js
Normal file
426
app/igneum-app/ui/live-dag.js
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
|
||||
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
|
||||
* 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the
|
||||
* empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence.
|
||||
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
|
||||
* Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case.
|
||||
* 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes
|
||||
* shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height;
|
||||
* onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with
|
||||
* autoHeight (true by default when the host set no CSS height on the canvas, forced either way by the option; never in
|
||||
* compact mode, the app's card keeps its own height) the renderer sets canvas.style.height itself and lets every lane through.
|
||||
* 2.0.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
|
||||
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
|
||||
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
|
||||
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
|
||||
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
|
||||
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
|
||||
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
|
||||
* edges, default .12, drawn only within laneReach lanes, default 2, and only a merged block's first parent unless selected),
|
||||
* othersScale (the others lane's size and alpha, default .75), provenGlow (a glow and ring on proven blocks, default on).
|
||||
* Replacement for the supplied live-dag.js. No framework, network writes or synthetic records.
|
||||
* Existing mount/push options and public methods are retained; see README.md for additions.
|
||||
* Every node, parent edge, proof segment and checkpoint is derived from the observer reply.
|
||||
* Motion interpolates presentation ONLY. It never promotes proof, inclusion or finality.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var instances = new WeakMap();
|
||||
var TAU = Math.PI * 2;
|
||||
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
|
||||
function finite(v) { return typeof v === 'number' && Number.isFinite(v); }
|
||||
function num(v) { return finite(v) ? v : null; }
|
||||
function fmt(v) { return v == null ? 'unknown' : Number(v).toLocaleString('en-GB'); }
|
||||
function rgba(c, a) {
|
||||
var s = String(c).trim(), h = s.replace('#', ''), rgb;
|
||||
if (/^#[0-9a-f]{3}$/i.test(s)) h = h.split('').map(function (x) { return x + x; }).join('');
|
||||
if (/^[0-9a-f]{6}$/i.test(h)) return 'rgba(' + parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16) + ',' + a + ')';
|
||||
rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
||||
return rgb ? 'rgba(' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ',' + a + ')' : s;
|
||||
}
|
||||
function rounded(ctx, x, y, w, h, r) {
|
||||
r = Math.max(0, Math.min(r, w/2, h/2)); ctx.beginPath(); ctx.moveTo(x+r,y);
|
||||
ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
|
||||
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
|
||||
}
|
||||
function cloneBlock(b) {
|
||||
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
|
||||
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
|
||||
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
|
||||
}
|
||||
function normalize(b) {
|
||||
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
|
||||
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
|
||||
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
|
||||
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
|
||||
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
|
||||
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
|
||||
}
|
||||
function mount(canvas, opts) {
|
||||
opts=opts||{};
|
||||
if (!canvas || typeof canvas.getContext!=='function') throw new TypeError('IgneumDag.mount requires a canvas element.');
|
||||
if (instances.has(canvas)) instances.get(canvas).destroy();
|
||||
var ctx=canvas.getContext('2d'); if(!ctx) throw new Error('A 2D canvas context is required.');
|
||||
var doc=canvas.ownerDocument, compact=!!opts.compact;
|
||||
var windowS=clamp(finite(opts.window)?opts.window:(compact?90:120),30,300);
|
||||
var maxBlocks=clamp(finite(opts.maxBlocks)?opts.maxBlocks:6000,100,20000);
|
||||
var lag=finite(opts.lagMs)?clamp(opts.lagMs,0,10000):2500;
|
||||
var fps=clamp(finite(opts.fps)?opts.fps:30,10,60), minFrame=1000/fps;
|
||||
// narrow (phones, under narrowBreak px, or opts.narrow true/false): window narrowWindow s until the viewer sets one, at most
|
||||
// narrowLanes lanes, nodes no smaller than narrowMinNode, hairlines off except the selected chain and the selected block,
|
||||
// checkpoint labels as the percent alone. The owner's call of 7 Oct 2026: faster drift, more gap, on a phone.
|
||||
var narrow=false,narrowBreak=finite(opts.narrowBreak)?opts.narrowBreak:720,narrowWindow=clamp(finite(opts.narrowWindow)?opts.narrowWindow:30,30,300),narrowLanes=clamp(finite(opts.narrowLanes)?opts.narrowLanes:4,2,12),narrowMinNode=finite(opts.narrowMinNode)?opts.narrowMinNode:11,baseWindow=0,windowTouched=false;
|
||||
// autoHeight: the host set no height on the canvas (its computed height is still the attribute's, 150 by default) and this
|
||||
// is not the compact card; read before size() touches the attribute
|
||||
var hostHeightSet=(function(){try{var cs=getComputedStyle(canvas),attr=canvas.getAttribute('height'),h=parseFloat(cs.height);return canvas.style.height!==''||!(finite(h)&&Math.round(h)===(attr===null?150:Number(attr)));}catch(e){return true;}})();
|
||||
var autoHeight=opts.autoHeight===true||(opts.autoHeight!==false&&!compact&&!hostHeightSet),laneHeightOpt=finite(opts.laneHeight)?opts.laneHeight:0,wantH=0,laneHNow=46;
|
||||
var maxLanes=clamp(finite(opts.maxLanes)?opts.maxLanes:7,2,12), minNode=finite(opts.minNode)?opts.minNode:9.5, hairAlpha=finite(opts.hairlineAlpha)?opts.hairlineAlpha:.12, laneReach=finite(opts.laneReach)?opts.laneReach:2, othersScale=finite(opts.othersScale)?opts.othersScale:.75, provenGlow=opts.provenGlow!==false;
|
||||
var mq=root.matchMedia?root.matchMedia('(prefers-reduced-motion: reduce)'):null;
|
||||
var reduced=mq?mq.matches:false, manualMotion=true;
|
||||
var mediaTheme=root.matchMedia?root.matchMedia('(prefers-color-scheme: dark)'):null;
|
||||
var col={}, W=0,H=0,dpr=1,padL=100,padR=22,padT=57,padB=37;
|
||||
var model=new Map(), view=new Map(), blocks=[], cps=[], latestCps=[], lanes=[], laneOf=new Map();
|
||||
var state='connecting',reason='Waiting for observer',destroyed=false,paused=false,following=true,engaged=false;
|
||||
var fixedRight=0,pausedRight=0,staleRight=null,reducedRight=Date.now()-lag, lastGood=0, lastData=null;
|
||||
var selected=null,hover=null,filter='all',queuedCount=0,invalid=0,omitted=0;
|
||||
var visible=true,raf=0,lastPaint=-Infinity,frameCount=0,lastT=0,freezeT=0;
|
||||
var pollTimer=0,healthTimer=0,timeout=0,controller=null,inflight=false,pollAgain=false,failures=0;
|
||||
var remove=[],ro=null,io=null,mo=null,drag=null,pinch=null;
|
||||
var lastCheckpointStates=new Map(),checkpointBursts=new Map();
|
||||
var initialAttributes={tabindex:canvas.getAttribute('tabindex'),role:canvas.getAttribute('role'),label:canvas.getAttribute('aria-label'),touch:canvas.style.touchAction,cursor:canvas.style.cursor};
|
||||
var tip=opts.tooltip||null,chip=opts.chip||null;
|
||||
var mine=typeof opts.mine==='function'?opts.mine:opts.mine?function(b){return b.miner===opts.mine;}:function(){return false;};
|
||||
function own(b){try{return !!mine(b);}catch(e){return false;}}
|
||||
function emit(name){if(destroyed || typeof opts[name]!=='function')return;var args=[].slice.call(arguments,1);try{opts[name].apply(null,args);}catch(e){console.error('IgneumDag '+name+' callback:',e);}}
|
||||
function on(target,name,handler,config){target.addEventListener(name,handler,config);remove.push(function(){target.removeEventListener(name,handler,config);});}
|
||||
function theme(){
|
||||
var s=getComputedStyle(doc.documentElement);
|
||||
function c(n,f){return s.getPropertyValue(n).trim()||f;}
|
||||
col={ember:c('--ember','#F2541B'),emberHi:c('--ember-hi','#FF6A2B'),molten:c('--molten','#FFB35C'),
|
||||
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
|
||||
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
|
||||
surface:c('--graphite','#16161A')};
|
||||
paint();redraw();
|
||||
}
|
||||
function rightTime(){
|
||||
if(paused)return pausedRight;
|
||||
if(!following)return fixedRight;
|
||||
if(state!=='live' && staleRight!==null)return staleRight;
|
||||
return reduced||!manualMotion?reducedRight:Date.now()-lag;
|
||||
}
|
||||
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
|
||||
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
|
||||
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
|
||||
function setState(s,r){
|
||||
if(state===s && reason===r)return;
|
||||
if(s!=='live' && state==='live')staleRight=rightTime();
|
||||
state=s;reason=r||null;
|
||||
if(s==='live')staleRight=null;
|
||||
emit('onState',s,reason);redraw();
|
||||
}
|
||||
function layout(){
|
||||
if(!baseWindow)baseWindow=windowS;
|
||||
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
|
||||
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
|
||||
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
|
||||
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
|
||||
blocks.forEach(function(b){if(b.ts>=start && b.ts<=end){counts.set(b.miner,(counts.get(b.miner)||0)+1);if(own(b))mineKeys.add(b.miner);}});
|
||||
var laneH=laneHeightOpt||(compact?26:narrow?40:46),cap=narrow?narrowLanes:maxLanes;laneHNow=laneH;
|
||||
var capacity=autoHeight?cap:Math.min(cap,Math.max(2,Math.floor((H-padT-padB)/laneH))),ids=Array.from(counts.keys());
|
||||
ids.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0)||(counts.get(b)-counts.get(a))||a.localeCompare(b);});
|
||||
var chosen=ids.slice(0,ids.length>capacity?capacity-1:capacity);
|
||||
// Preserve existing key order instead of re-ranking every poll. Always keep the user's lane visible.
|
||||
var keep=lanes.filter(function(id){return chosen.includes(id);});
|
||||
chosen.forEach(function(id){if(!keep.includes(id))keep.push(id);});
|
||||
keep.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0);});
|
||||
lanes=keep;if(ids.length>chosen.length)lanes.push('__others__');
|
||||
laneOf=new Map();lanes.forEach(function(id,i){laneOf.set(id,i);});
|
||||
// the wanted box height for these lanes (2.0.4): reported on change; applied here only under autoHeight
|
||||
var nextH=padT+padB+Math.max(2,lanes.length)*laneH;
|
||||
if(nextH!==wantH){wantH=nextH;emit('onSize',wantH,{lanes:lanes.length,laneHeight:laneH,narrow:narrow,compact:compact});if(autoHeight&&canvas.style.height!==wantH+'px')canvas.style.height=wantH+'px';}
|
||||
var now=performance.now();
|
||||
blocks.forEach(function(b){var i=laneOf.has(b.miner)?laneOf.get(b.miner):Math.max(0,lanes.length-1);
|
||||
var y=padT+(i+.5)*(H-padT-padB)/Math.max(1,lanes.length);
|
||||
if(!finite(b.y)){b.y=y;b.fromY=y;b.targetY=y;}
|
||||
if(b.targetY!==y){b.fromY=b.y;b.targetY=y;b.laneAt=now;}
|
||||
});
|
||||
}
|
||||
function size(){
|
||||
if(destroyed)return;
|
||||
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
|
||||
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
|
||||
W=nextW;H=nextH;dpr=nextDpr;
|
||||
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
|
||||
}
|
||||
function syncView(){
|
||||
var now=performance.now(),first=view.size===0, next=new Map();
|
||||
model.forEach(function(b,hash){var old=view.get(hash),v=Object.assign({},b);
|
||||
v.born=old?old.born:first?now-3000:now;v.newArrival=old?old.newArrival:!first;v.visibleAt=old?old.visibleAt:null;v.changed=old && (old.proven!==b.proven||old.locked!==b.locked||old.color!==b.color||old.shards.map(function(s){return s.state;}).join(',')!==b.shards.map(function(s){return s.state;}).join(','))?now:old?old.changed:now-3000;
|
||||
v.y=old?old.y:NaN;v.fromY=old?old.fromY:NaN;v.targetY=old?old.targetY:NaN;v.laneAt=old?old.laneAt:now;
|
||||
next.set(hash,v);
|
||||
});
|
||||
view=next;blocks=Array.from(view.values()).sort(function(a,b){return a.ts-b.ts||a.hash.localeCompare(b.hash);});
|
||||
cps=latestCps.map(function(c){return Object.assign({},c);});
|
||||
cps.forEach(function(c){var prev=lastCheckpointStates.get(c.index);if(prev==='pending'&&c.state==='locked')checkpointBursts.set(c.index,now);lastCheckpointStates.set(c.index,c.state);});
|
||||
var cpKeys=new Set(cps.map(function(c){return c.index;}));lastCheckpointStates.forEach(function(_,key){if(!cpKeys.has(key)){lastCheckpointStates.delete(key);checkpointBursts.delete(key);}});
|
||||
layout();
|
||||
if(selected && !view.has(selected)){selected=null;emit('onSelect',null,'expired');}
|
||||
else if(selected)emit('onSelect',cloneBlock(view.get(selected)),'update');
|
||||
if(hover&&!view.has(hover))hover=null;
|
||||
queuedCount=0;
|
||||
}
|
||||
function push(d){
|
||||
if(destroyed)return false;
|
||||
if(!d||d.ok!==true||!d.state||typeof d.state!=='object'||!Array.isArray(d.blocks)){
|
||||
setState('failed','Invalid observer reply; expected {ok, state, blocks}.');return false;
|
||||
}
|
||||
var now=Date.now(), incoming=[], bad=0;
|
||||
d.blocks.forEach(function(b){var n=normalize(b);if(n)incoming.push(n);else bad++;});
|
||||
if(d.blocks.length && !incoming.length){setState('failed','Observer reply contains no valid block records.');return false;}
|
||||
invalid=bad;lastGood=now;reducedRight=now-lag;failures=0;lastData=d;
|
||||
incoming.forEach(function(b){if(!model.has(b.hash))queuedCount++;model.set(b.hash,b);});
|
||||
var newest=0;model.forEach(function(b){newest=Math.max(newest,b.ts);});
|
||||
// Bounded retention uses the newest observed header, not a local wall-clock guess.
|
||||
var cut=newest-340000;model.forEach(function(b,h){if(b.ts<cut)model.delete(h);});
|
||||
omitted=0;
|
||||
if(model.size>maxBlocks){var ordered=Array.from(model.values()).sort(function(a,b){return b.ts-a.ts;});omitted=model.size-maxBlocks;model=new Map(ordered.slice(0,maxBlocks).map(function(b){return[b.hash,b];}));}
|
||||
latestCps=d.finality&&Array.isArray(d.finality.checkpoints)?d.finality.checkpoints.filter(function(c){return c&&finite(c.blue_score)&&['pending','locked'].includes(c.state);}).slice(-128).map(function(c){return {index:c.index,blue_score:c.blue_score,state:c.state,fraction_total:finite(c.fraction_total)&&c.fraction_total>=0&&c.fraction_total<=1?c.fraction_total:null};}):[];
|
||||
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
|
||||
else setState('live',null);
|
||||
if(!paused)syncView();
|
||||
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
|
||||
}
|
||||
function stats(){
|
||||
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
|
||||
return {blocks:v.length,included:v.filter(function(b){return b.color==='blue';}).length,excluded:v.filter(function(b){return b.color==='red';}).length,
|
||||
selectedChain:v.filter(function(b){return b.chain;}).length,proven:v.filter(function(b){return b.proven;}).length,
|
||||
own:v.filter(own).length,minerKeys:new Set(v.map(function(b){return b.miner;})).size,
|
||||
lockedCheckpoints:cps.filter(function(c){return c.state==='locked';}).length,paused:paused,following:following,queued:paused?queuedCount:0,
|
||||
invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,narrow:narrow,lanes:lanes.length,reducedMotion:reduced||!manualMotion,state:state,
|
||||
// the lane geometry (2.0.4), so a page that sizes its own box reads no constants
|
||||
laneHeight:laneHNow,padT:padT,padB:padB,capacity:narrow?narrowLanes:maxLanes,wantedHeight:wantH,autoHeight:autoHeight};
|
||||
}
|
||||
function poll(){
|
||||
if(destroyed||opts.poll===false||doc.hidden)return;
|
||||
if(inflight){pollAgain=true;return;}
|
||||
clearTimeout(pollTimer);inflight=true;controller=new AbortController();var signal=controller.signal;
|
||||
timeout=setTimeout(function(){if(controller)controller.abort();},finite(opts.timeoutMs)?opts.timeoutMs:8000);
|
||||
var url;
|
||||
try{url=new URL(opts.url||'/api/live',doc.baseURI);if(!['https:','http:'].includes(url.protocol))throw new Error('Use an HTTP(S) observer URL.');url.searchParams.set('window',String(Math.round(windowS)));}
|
||||
catch(e){finishError(e);return;}
|
||||
fetch(url.href,{cache:'no-store',signal:signal,credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}).then(function(d){if(destroyed)return;if(!push(d))throw new Error('Invalid observer reply');}).catch(finishError).finally(finish);
|
||||
function finishError(e){if(destroyed||doc.hidden)return;failures++;setState(failures>=2?'failed':'stale','Observer unavailable; retrying'+(e&&e.name==='AbortError'?' (timeout)':''));if(!url)finish();}
|
||||
function finish(){clearTimeout(timeout);timeout=0;controller=null;inflight=false;if(destroyed||opts.poll===false||doc.hidden)return;var again=pollAgain;pollAgain=false;pollTimer=setTimeout(poll,again?0:Math.min(30000,2000*Math.pow(2,Math.min(failures,4))));}
|
||||
}
|
||||
function passes(b){return filter==='all'||filter==='chain'&&b.chain||filter==='mine'&&own(b);}
|
||||
function curve(b,p){var mx=(b.x+p.x)/2;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.bezierCurveTo(mx,p.y,mx,b.y,b.x,b.y);}
|
||||
function pointOn(b,p,t){var u=1-t,mx=(b.x+p.x)/2;return {x:u*u*u*p.x+3*u*u*t*mx+3*u*t*t*mx+t*t*t*b.x,y:u*u*u*p.y+3*u*u*t*p.y+3*u*t*t*b.y+t*t*t*b.y};}
|
||||
function lockIcon(x,y,size,color){ctx.strokeStyle=color;ctx.lineWidth=1.25;rounded(ctx,x-size*.42,y,size*.84,size*.65,1.5);ctx.stroke();ctx.beginPath();ctx.arc(x,y,size*.25,Math.PI,0);ctx.stroke();}
|
||||
function draw(t){
|
||||
if(destroyed||!W||!H)return;
|
||||
frameCount++;var rt=rightTime(),left=rt-windowS*1000,animate=!reduced&&manualMotion&&!paused&&state==='live';var clock=paused?freezeT:t;
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);ctx.fillStyle=col.bg;ctx.fillRect(0,0,W,H);
|
||||
var plotW=W-padL-padR, plotH=H-padT-padB,lh=plotH/Math.max(1,lanes.length);
|
||||
// Sparse time ticks; true horizontal coordinate is header time, not DAA spacing.
|
||||
ctx.lineWidth=1;ctx.setLineDash([2,7]);
|
||||
for(var g=0;g<=6;g++){var gx=padL+plotW*g/6;ctx.strokeStyle=rgba(col.line,.52);ctx.beginPath();ctx.moveTo(gx,padT-10);ctx.lineTo(gx,H-padB);ctx.stroke();}
|
||||
ctx.setLineDash([]);
|
||||
lanes.forEach(function(lane,i){var y=padT+(i+.5)*lh, ownLane=blocks.some(function(b){return b.miner===lane&&own(b);});
|
||||
if(ownLane){var grad=ctx.createLinearGradient(padL,0,W,0);grad.addColorStop(0,rgba(col.molten,.085));grad.addColorStop(1,rgba(col.molten,.005));ctx.fillStyle=grad;rounded(ctx,padL-5,y-lh*.4,plotW+5,lh*.8,7);ctx.fill();}
|
||||
ctx.strokeStyle=rgba(ownLane?col.molten:col.line,ownLane?.20:.65);ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-padR,y);ctx.stroke();
|
||||
if(padL>50){ctx.font='500 10px ui-monospace, SFMono-Regular, Consolas, monospace';ctx.textBaseline='middle';ctx.textAlign='left';ctx.fillStyle=ownLane?col.molten:col.ash;
|
||||
ctx.fillText(ownLane?'YOUR KEY':lane==='__others__'?'OTHERS':lane.slice(0,8),12,y-5);
|
||||
ctx.font='8px ui-monospace, monospace';ctx.fillStyle=rgba(col.ash,.78);ctx.fillText(ownLane?'YOUR BLOCKS':lane==='__others__'?'GROUPED KEYS':'MINER KEY',12,y+10);
|
||||
}
|
||||
});
|
||||
var cpLabels=[];
|
||||
cps.forEach(function(c){
|
||||
// Exact score only. Never pin a checkpoint to an unrelated nearest block.
|
||||
var candidates=blocks.filter(function(b){return b.blue_score===c.blue_score;});
|
||||
var ref=candidates.find(function(b){return b.locked;})||candidates.find(function(b){return b.chain;})||(candidates.length===1?candidates[0]:null);
|
||||
if(!ref||ref.ts<left||ref.ts>rt)return;
|
||||
var x=xOf(ref.ts,rt),locked=c.state==='locked',burstAt=checkpointBursts.get(c.index),burst=animate&&burstAt?Math.max(0,1-(t-burstAt)/1400):0;
|
||||
var band=ctx.createLinearGradient(x-20,0,x+36,0);band.addColorStop(0,rgba(col.ember,0));band.addColorStop(.38,rgba(col.ember,locked?.09:.035));band.addColorStop(1,rgba(col.ember,0));ctx.fillStyle=band;ctx.fillRect(x-20,padT-12,56,plotH+12);
|
||||
ctx.strokeStyle=rgba(col.ember,locked?.6:.32);ctx.lineWidth=locked?1.5:1;ctx.setLineDash(locked?[]:[3,6]);ctx.beginPath();ctx.moveTo(x,padT-14);ctx.lineTo(x,H-padB);ctx.stroke();ctx.setLineDash([]);
|
||||
if(burst>0){ctx.strokeStyle=rgba(col.molten,burst*.5);ctx.lineWidth=1;ctx.strokeRect(x-(1-burst)*42,padT-12,(1-burst)*84,plotH+12);}
|
||||
if(!compact && (narrow || W>510))cpLabels.push({x:x,cp:c,locked:locked});
|
||||
});
|
||||
cpLabels.sort(function(a,b){return b.x-a.x;});var cpLast=Infinity;
|
||||
cpLabels.forEach(function(p){var pct=p.cp.fraction_total===null||p.cp.fraction_total===undefined?'':Math.round(p.cp.fraction_total*100)+'%',label=narrow?(pct||(p.locked?'LOCKED':'PENDING')):(p.locked?'LOCKED':'PENDING')+(pct?' '+pct:''),w=label.length*5.5+(narrow?14:22),x=clamp(p.x-w/2,padL,W-padR-w);if(x+w+8>cpLast)return;cpLast=x;
|
||||
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(col.ember,p.locked?.45:.23);rounded(ctx,x,17,w,22,4);ctx.fill();ctx.stroke();ctx.font='9px ui-monospace, monospace';ctx.fillStyle=p.locked?col.ember:col.ash;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(label,x+w/2,28);
|
||||
});
|
||||
// Animate lane transitions without changing their underlying key identity.
|
||||
blocks.forEach(function(b){b.x=xOf(b.ts,rt);var progress=animate?clamp((clock-b.laneAt)/450,0,1):1;progress=1-Math.pow(1-progress,3);b.y=b.fromY+(b.targetY-b.fromY)*progress;b.on=b.ts>=left&&b.ts<=rt&&passes(b);if(b.on&&b.newArrival&&b.visibleAt===null){b.visibleAt=t;b.born=t;}});
|
||||
ctx.save();ctx.beginPath();ctx.rect(padL-1,padT-15,plotW+2,plotH+25);ctx.clip();
|
||||
blocks.forEach(function(b){if(!b.on)return;var bSel=selected&&b.hash===selected;b.parents.forEach(function(hash,pi){var p=view.get(hash);if(!p||!passes(p)||p.x>W-padR||p.x<padL-plotW*.2)return;
|
||||
var path=b.chain&&p.chain,selectedEdge=selected&&(b.hash===selected||p.hash===selected);
|
||||
if(!path&&!selectedEdge){if(narrow||pi>0)return;var li=laneOf.has(b.miner)?laneOf.get(b.miner):lanes.length-1,lp=laneOf.has(p.miner)?laneOf.get(p.miner):lanes.length-1;if(Math.abs(li-lp)>laneReach)return;}
|
||||
if(path||selectedEdge){ctx.strokeStyle=rgba(path?col.ember:col.bone,path?.1:.10);ctx.lineWidth=path?8:4;curve(b,p);ctx.stroke();}
|
||||
ctx.strokeStyle=rgba(path?col.ember:selectedEdge?col.bone:b.color==='red'?col.red:col.ash,path?.68:selectedEdge?.7:hairAlpha);ctx.lineWidth=path?1.65:selectedEdge?1.4:1;curve(b,p);ctx.stroke();
|
||||
var age=t-b.born;if(animate&&age>=0&&age<1400){var f=clamp(age/1150,0,1),pt=pointOn(b,p,f);ctx.shadowBlur=10;ctx.shadowColor=path?col.ember:col.blue;ctx.fillStyle=path?col.molten:col.blue;ctx.beginPath();ctx.arc(pt.x,pt.y,path?2.2:1.65,0,TAU);ctx.fill();ctx.shadowBlur=0;}
|
||||
});});
|
||||
var S=compact?6.5:narrow?clamp(lh*.24,narrowMinNode,15):clamp(lh*.22,minNode,13);
|
||||
blocks.forEach(function(b){if(!b.on)return;
|
||||
var age=t-b.born,newness=animate?Math.max(clamp(1-age/1700,0,1),clamp(1-(t-b.changed)/900,0,1)*.65):0;
|
||||
var ink=b.color==='red'?col.red:b.chain?col.ember:b.color==='blue'?col.blue:col.ash;
|
||||
var isOwn=own(b),isSelected=b.hash===selected||b.hash===hover,inOthers=!laneOf.has(b.miner),sz=S*(1+(newness*.15))*(inOthers?othersScale:1);
|
||||
if(newness>0||isOwn||isSelected){var glow=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.8);glow.addColorStop(0,rgba(isOwn?col.molten:ink,newness*.26+(isSelected?.15:isOwn?.06:0)));glow.addColorStop(1,rgba(ink,0));ctx.fillStyle=glow;ctx.fillRect(b.x-sz*4,b.y-sz*4,sz*8,sz*8);}
|
||||
ctx.globalAlpha=(b.color==='red'?.78:1)*(inOthers&&!isSelected&&!isOwn?othersScale:1);
|
||||
// a proven block glows and carries a ring, so the proof reads from across the room
|
||||
if(provenGlow&&b.proven){var pg=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.2);pg.addColorStop(0,rgba(ink,.26));pg.addColorStop(1,rgba(ink,0));ctx.fillStyle=pg;ctx.fillRect(b.x-sz*3.4,b.y-sz*3.4,sz*6.8,sz*6.8);ctx.strokeStyle=rgba(ink,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+5,0,TAU);ctx.stroke();}
|
||||
// Inset tiles: inclusion is the outline; proof is the core, never the same status.
|
||||
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(ink,b.chain?.95:.8);ctx.lineWidth=b.chain?1.75:1.25;rounded(ctx,b.x-sz,b.y-sz,2*sz,2*sz,3);ctx.fill();ctx.stroke();
|
||||
var tile=ctx.createLinearGradient(b.x,b.y-sz,b.x,b.y+sz);tile.addColorStop(0,rgba(ink,b.proven?.5:.16));tile.addColorStop(1,rgba(ink,b.proven?.22:.025));ctx.fillStyle=tile;rounded(ctx,b.x-sz+2,b.y-sz+2,2*sz-4,2*sz-4,2);ctx.fill();
|
||||
if(b.proven){ctx.strokeStyle=rgba(b.color==='red'?col.red:col.bone,.9);ctx.lineWidth=1.3;ctx.beginPath();ctx.moveTo(b.x-3,b.y);ctx.lineTo(b.x-1,b.y+2);ctx.lineTo(b.x+3,b.y-2);ctx.stroke();}
|
||||
else if(b.color==='red'){ctx.strokeStyle=ink;ctx.lineWidth=1.1;ctx.beginPath();ctx.moveTo(b.x-2,b.y-2);ctx.lineTo(b.x+2,b.y+2);ctx.moveTo(b.x+2,b.y-2);ctx.lineTo(b.x-2,b.y+2);ctx.stroke();}
|
||||
else{ctx.fillStyle=rgba(ink,.65);ctx.fillRect(b.x-1.3,b.y-1.3,2.6,2.6);}
|
||||
if(!compact && b.shards.length){var total=b.shards.length,shown=Math.min(total,8),gap=1.6,barW=2*sz,seg=(barW-gap*(shown-1))/shown;
|
||||
for(var k=0;k<shown;k++){var shard=b.shards[k].state,alpha=shard==='proving'?(animate?.5+.4*Math.sin(t/300+k):.8):1;
|
||||
ctx.fillStyle=rgba(shard==='paid'?col.molten:shard==='verified'?col.bone:shard==='proving'?col.ember:col.line2,alpha);ctx.fillRect(b.x-sz+k*(seg+gap),b.y+sz+4,Math.max(1,seg),2);
|
||||
}
|
||||
}
|
||||
if(isOwn){ctx.strokeStyle=col.molten;ctx.lineWidth=1.2;var q=sz+4;ctx.beginPath();[[1,1],[1,-1],[-1,1],[-1,-1]].forEach(function(v){ctx.moveTo(b.x+v[0]*(q-4),b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*(q-4));});ctx.stroke();}
|
||||
if(b.locked){ctx.strokeStyle=rgba(col.ember,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+8,0,TAU);ctx.stroke();}
|
||||
if(b.final){ctx.fillStyle=rgba(col.ember,.75);ctx.beginPath();ctx.arc(b.x+sz-1,b.y-sz-2,2,0,TAU);ctx.fill();}
|
||||
if(isSelected){ctx.strokeStyle=col.bone;ctx.lineWidth=1;ctx.setLineDash([3,3]);rounded(ctx,b.x-sz-8,b.y-sz-8,2*sz+16,2*sz+16,6);ctx.stroke();ctx.setLineDash([]);}
|
||||
ctx.globalAlpha=1;
|
||||
});ctx.restore();
|
||||
// Timeline and feed edge. No fabricated DAA ticks or estimated checkpoint positions.
|
||||
ctx.font=(compact?'8':'9')+'px ui-monospace, monospace';ctx.fillStyle=col.ash;ctx.textBaseline='middle';
|
||||
var ticks=W<420?2:4;
|
||||
for(var q=0;q<=ticks;q++){var tx=padL+plotW*q/ticks;ctx.textAlign=q===0?'left':q===ticks?'right':'center';var label=q===ticks?(paused?'PAUSED':!following?'HISTORY':state==='live'?'OBSERVED NOW':state.toUpperCase()):'-'+Math.round(windowS*(1-q/ticks))+'s';ctx.fillText(label,tx,H-(compact?8:13));}
|
||||
if(!compact){var visibleBlocks=blocks.filter(function(b){return b.on;});ctx.font='8px ui-monospace, monospace';ctx.textAlign='left';ctx.fillStyle=rgba(col.ash,.8);if(padL>50)ctx.fillText('HEADER TIME',12,H-13);
|
||||
if(visibleBlocks.length && W>600){var last=visibleBlocks[visibleBlocks.length-1];ctx.textAlign='right';ctx.fillText('DAA '+fmt(last.daa),W-padR,8);}
|
||||
}
|
||||
if(state==='live'&&following&&!paused){ctx.strokeStyle=rgba(col.ember,.3);ctx.beginPath();ctx.moveTo(W-padR,padT-9);ctx.lineTo(W-padR,H-padB);ctx.stroke();ctx.fillStyle=col.ember;ctx.beginPath();ctx.arc(W-padR,padT-12,2.3,0,TAU);ctx.fill();}
|
||||
if(!blocks.some(function(b){return b.on;})){
|
||||
ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.font='12px system-ui, sans-serif';ctx.fillText(state==='connecting'?'Waiting for observer data':filter==='mine'?'No blocks for your key in this window':filter==='chain'?'No selected-chain blocks in this window':'No blocks in this time window',padL+plotW/2,H/2);
|
||||
}
|
||||
var active=selected?view.get(selected):hover?view.get(hover):null;if(active&&active.on)showTip(active);else if(tip)tip.hidden=true;
|
||||
lastT=t;
|
||||
}
|
||||
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
|
||||
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
|
||||
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
|
||||
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
|
||||
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
|
||||
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
|
||||
function words(b,nowMs){
|
||||
if(!b)return {title:'No block selected',lines:[]};
|
||||
var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending';
|
||||
var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final');
|
||||
return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner,
|
||||
'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'),
|
||||
'Shards: '+shardWords(b,nowMs).summary]};
|
||||
}
|
||||
function showTip(b){
|
||||
if(!tip)return;
|
||||
if(!b){tip.hidden=true;return;}
|
||||
var w=words(b),key=w.title+'|'+w.lines.join('|');
|
||||
if(tip.dataset.igneumKey!==key){tip.replaceChildren();var title=doc.createElement('b');title.textContent=w.title;tip.appendChild(title);w.lines.forEach(function(line){var p=doc.createElement('span');p.textContent=line;p.style.display='block';tip.appendChild(p);});tip.dataset.igneumKey=key;}
|
||||
tip.hidden=false;tip.style.pointerEvents='none';tip.style.position='absolute';tip.style.left='0px';tip.style.top='0px';tip.style.maxWidth=Math.max(160,W-24)+'px';tip.style.whiteSpace='normal';tip.style.overflowWrap='anywhere';
|
||||
var host=tip.offsetParent||canvas.parentElement,cr=canvas.getBoundingClientRect(),pr=host.getBoundingClientRect();
|
||||
var tw=tip.offsetWidth,th=tip.offsetHeight;var x=clamp(b.x+16,8,Math.max(8,W-tw-8)),y=b.y-th-18;if(y<8)y=Math.min(H-th-8,b.y+22);
|
||||
tip.style.transform='translate('+Math.round(cr.left-pr.left-host.clientLeft+x)+'px,'+Math.round(cr.top-pr.top-host.clientTop+Math.max(8,y))+'px)';
|
||||
}
|
||||
function hit(ev){var p=eventXY(ev),best=null,dist=22*22;blocks.forEach(function(b){if(!b.on)return;var d=(b.x-p.x)**2+(b.y-p.y)**2;if(d<dist){dist=d;best=b;}});return best;}
|
||||
function select(hash,why){if(hash!==null&&!view.has(hash))return false;selected=hash;showTip(hash?view.get(hash):null);emit('onSelect',hash?cloneBlock(view.get(hash)):null,why||'select');redraw();return true;}
|
||||
function engage(onValue){var value=!!onValue;if(engaged===value)return;engaged=value;canvas.classList.toggle('engaged',value);if(chip)chip.hidden=!value;emit('onEngage',value);}
|
||||
function setWindow(seconds){var n=Math.round(clamp(seconds,30,300));if(!finite(n)||n===windowS)return;windowTouched=true;windowS=n;layout();emit('onWindow',n);emit('onStats',stats());poll();redraw();}
|
||||
function zoom(f){if(!finite(f)||f<=0)return;setWindow(windowS*f);}
|
||||
function follow(){following=true;fixedRight=0;if(paused)pausedRight=Date.now()-lag;layout();emit('onFollow',true);redraw();}
|
||||
function setPaused(p){p=!!p;if(paused===p)return;
|
||||
if(p){pausedRight=rightTime();freezeT=performance.now();paused=true;stopFrame();}
|
||||
else{paused=false;syncView();}
|
||||
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed',String(paused));opts.pauseButton.textContent=paused?'Resume':'Pause';}
|
||||
emit('onPause',paused);emit('onStats',stats());redraw();
|
||||
}
|
||||
function setFilter(value){if(!['all','chain','mine'].includes(value))return;filter=value;select(null,'filter');layout();redraw();}
|
||||
function setMotion(value){manualMotion=!!value;reducedRight=Date.now()-lag;stopFrame();redraw();}
|
||||
function panTo(right){following=false;fixedRight=Math.min(right,Date.now()-lag);if(paused)pausedRight=fixedRight;emit('onFollow',false);layout();redraw();}
|
||||
canvas.setAttribute('tabindex',canvas.getAttribute('tabindex')||'0');canvas.setAttribute('role','img');
|
||||
if(!canvas.getAttribute('aria-label'))canvas.setAttribute('aria-label','Interactive block graph. Arrow keys inspect blocks. Plus and minus zoom. Space pauses, F follows, Escape releases.');
|
||||
canvas.style.touchAction='pan-y';
|
||||
on(canvas,'pointerdown',function(ev){if(ev.pointerType==='touch'||ev.button!==0)return;canvas.focus({preventScroll:true});engage(true);drag={id:ev.pointerId,x:ev.clientX,start:rightTime(),moved:false};canvas.setPointerCapture(ev.pointerId);});
|
||||
on(canvas,'pointermove',function(ev){
|
||||
if(drag&&drag.id===ev.pointerId){var dx=ev.clientX-drag.x;if(Math.abs(dx)>4)drag.moved=true;if(drag.moved){panTo(drag.start-dx*windowS*1000/Math.max(1,W-padL-padR));canvas.style.cursor='grabbing';return;}}
|
||||
var b=hit(ev);hover=b?b.hash:null;canvas.style.cursor=b?'pointer':'grab';if(!selected)showTip(b);if(!continuous())redraw();
|
||||
});
|
||||
on(canvas,'pointerup',function(ev){if(ev.pointerType==='touch')return;if(!drag||drag.id!==ev.pointerId)return;var moved=drag.moved;drag=null;if(canvas.hasPointerCapture(ev.pointerId))canvas.releasePointerCapture(ev.pointerId);canvas.style.cursor='grab';if(!moved){var b=hit(ev);select(b&&selected!==b.hash?b.hash:null,'pointer');}});
|
||||
on(canvas,'pointercancel',function(){drag=null;});
|
||||
on(canvas,'pointerleave',function(){hover=null;if(!drag)engage(false);if(!selected)showTip(null);redraw();});
|
||||
on(canvas,'wheel',function(ev){if(!ev.ctrlKey&&!ev.metaKey&&!engaged)return;if(Math.abs(ev.deltaY)<1)return;ev.preventDefault();zoom(ev.deltaY>0?1.15:1/1.15);},{passive:false});
|
||||
var touchStart=null;
|
||||
function dist(touches){return Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY);}
|
||||
on(canvas,'touchstart',function(ev){if(ev.touches.length===2){pinch={distance:dist(ev.touches),window:windowS};touchStart=null;}else if(ev.touches.length===1){touchStart={x:ev.touches[0].clientX,y:ev.touches[0].clientY};}},{passive:true});
|
||||
on(canvas,'touchmove',function(ev){if(ev.touches.length===2&&pinch){ev.preventDefault();setWindow(pinch.window*pinch.distance/Math.max(1,dist(ev.touches)));}else if(touchStart&&ev.touches.length){if(Math.hypot(ev.touches[0].clientX-touchStart.x,ev.touches[0].clientY-touchStart.y)>8)touchStart=null;}},{passive:false});
|
||||
on(canvas,'touchend',function(ev){if(pinch){if(ev.touches.length<2)pinch=null;touchStart=null;return;}if(touchStart&&ev.changedTouches.length){var b=hit(ev.changedTouches[0]);select(b&&selected!==b.hash?b.hash:null,'touch');touchStart=null;}},{passive:true});
|
||||
on(canvas,'keydown',function(ev){
|
||||
var key=ev.key;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Home','End'].includes(key)){
|
||||
ev.preventDefault();var list=blocks.filter(function(b){return b.on;}),i=list.findIndex(function(b){return b.hash===selected;}),next;
|
||||
if(!list.length)return;if(key==='Home')next=0;else if(key==='End')next=list.length-1;else next=i<0?list.length-1:clamp(i+(['ArrowRight','ArrowDown'].includes(key)?1:-1),0,list.length-1);select(list[next].hash,'keyboard');
|
||||
}else if(key===' '){ev.preventDefault();setPaused(!paused);}else if(key==='+'||key==='='){ev.preventDefault();zoom(1/1.2);}else if(key==='-'){ev.preventDefault();zoom(1.2);}else if(key.toLowerCase()==='f'){ev.preventDefault();follow();}
|
||||
});
|
||||
on(doc,'keydown',function(ev){if(ev.key==='Escape'&&(engaged||doc.activeElement===canvas)){engage(false);select(null,'escape');}});
|
||||
on(doc,'pointerdown',function(ev){if(engaged&&ev.target!==canvas&&!(chip&&chip.contains(ev.target)))engage(false);});
|
||||
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed','false');on(opts.pauseButton,'click',function(){setPaused(!paused);});}
|
||||
on(root,'resize',size);
|
||||
if('ResizeObserver'in root){ro=new ResizeObserver(size);ro.observe(canvas);}
|
||||
if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){visible=es[0].isIntersecting;if(visible)redraw();else stopFrame();},{threshold:0});io.observe(canvas);}
|
||||
if('MutationObserver'in root){mo=new MutationObserver(theme);mo.observe(doc.documentElement,{attributes:true,attributeFilter:['data-theme','class','style']});}
|
||||
if(mq&&mq.addEventListener)on(mq,'change',function(e){reduced=e.matches;reducedRight=Date.now()-lag;stopFrame();redraw();});
|
||||
if(mediaTheme&&mediaTheme.addEventListener)on(mediaTheme,'change',theme);
|
||||
on(doc,'visibilitychange',function(){if(doc.hidden){stopFrame();clearTimeout(pollTimer);if(controller)controller.abort();}else{poll();redraw();}});
|
||||
healthTimer=setInterval(function(){if(destroyed||doc.hidden||!lastGood)return;if(state==='live'&&Date.now()-lastGood>(finite(opts.staleAfterMs)?opts.staleAfterMs:15000))setState('stale','No observer update for '+Math.round((Date.now()-lastGood)/1000)+' s');},1000);
|
||||
function destroy(){if(destroyed)return;destroyed=true;stopFrame();clearTimeout(pollTimer);clearTimeout(timeout);clearInterval(healthTimer);if(controller)controller.abort();remove.forEach(function(fn){fn();});if(ro)ro.disconnect();if(io)io.disconnect();if(mo)mo.disconnect();if(tip)tip.hidden=true;canvas.classList.remove('engaged');
|
||||
[['tabindex',initialAttributes.tabindex],['role',initialAttributes.role],['aria-label',initialAttributes.label]].forEach(function(pair){if(pair[1]===null)canvas.removeAttribute(pair[0]);else canvas.setAttribute(pair[0],pair[1]);});canvas.style.touchAction=initialAttributes.touch;canvas.style.cursor=initialAttributes.cursor;instances.delete(canvas);model.clear();view.clear();blocks=[];
|
||||
}
|
||||
var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getWantedHeight:function(){return wantH;},getState:function(){return {state:state,reason:reason};},redraw:redraw,words:words,
|
||||
destroy:destroy,follow:follow,setWindow:setWindow,setFilter:setFilter,setMotion:setMotion,select:select,getStats:stats,
|
||||
getViewRange:function(){return {start:rightTime()-windowS*1000,end:rightTime(),filter:filter};},getBlocks:function(){return blocks.map(cloneBlock);},getCheckpoints:function(){return cps.map(function(c){return Object.assign({},c);});},
|
||||
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
|
||||
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
|
||||
}
|
||||
// the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the
|
||||
// block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off
|
||||
// the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited.
|
||||
// With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid.
|
||||
function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';}
|
||||
function shardWords(b,nowMs){
|
||||
var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[];
|
||||
if(!sh.length){
|
||||
if(!b)return {summary:'',items:[],state:'none'};
|
||||
if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'};
|
||||
if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'};
|
||||
if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'};
|
||||
if(age<10)return {summary:'shard plan loading',items:[],state:'loading'};
|
||||
return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'};
|
||||
}
|
||||
var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};});
|
||||
var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0};
|
||||
sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;});
|
||||
var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age));
|
||||
return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'};
|
||||
}
|
||||
// key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both
|
||||
// pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes
|
||||
// {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a
|
||||
// drawn colour drifts from this list. Shapes: square (outlined), circle (the checkpoint ring), tick (the proof mark),
|
||||
// ringed (the own-block halo); faded dims the swatch as the scene dims excluded blocks.
|
||||
function legend(o){
|
||||
var own={id:'you',label:'Your blocks',token:'--molten',shape:'ringed'};
|
||||
var list=[{id:'pending',label:'Pending',token:'--ash',shape:'square'},{id:'included',label:'Included',token:'--included',shape:'square'},
|
||||
{id:'excluded',label:'Excluded',token:'--excluded',shape:'square',faded:true},{id:'chain',label:'Selected chain',token:'--ember',shape:'square',fill:'--ember-12'},
|
||||
{id:'proven',label:'Proven',token:'--bone',shape:'tick'},{id:'locked',label:'Locked checkpoint',token:'--ember',shape:'circle'}];
|
||||
return (o&&o.mine?[own]:[]).concat(list);
|
||||
}
|
||||
// renders the key into el: <span><i class="lg <shape> [faded]" style="--lg:var(token)"></i>Label</span>…, keeping any
|
||||
// children the page already placed there (the app's source line) after the entries
|
||||
function renderLegend(el,o){
|
||||
if(!el)return [];var entries=legend(o),doc=el.ownerDocument,keep=Array.prototype.slice.call(el.childNodes).filter(function(n){return !(n.getAttribute&&n.getAttribute('data-lg'));});
|
||||
while(el.firstChild)el.removeChild(el.firstChild);
|
||||
entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);});
|
||||
keep.forEach(function(n){el.appendChild(n);});return entries;
|
||||
}
|
||||
root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'};
|
||||
})(window);
|
||||
|
|
@ -96,7 +96,7 @@ test('update wording: available, downloading with percent, installing, failed wi
|
|||
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
|
||||
// updated: gone within 60 s of the new version starting
|
||||
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
|
||||
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
|
||||
assert.match(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59, now: 1_800_000_000 })).text, /^Updated to 0\.3\.6 at \d\d:\d\d\.$/);
|
||||
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
|
||||
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
|
||||
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
|
||||
|
|
@ -106,22 +106,26 @@ test('update wording: available, downloading with percent, installing, failed wi
|
|||
|
||||
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
|
||||
const r = jobNotice(run(), NOW);
|
||||
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
|
||||
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
|
||||
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
|
||||
assert.equal(r.text, 'A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8.');
|
||||
assert.equal(r.detail, 'shard benchmark · RESULT shard=2 prove_s=39.8', 'the technical line goes behind the chevron');
|
||||
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'A job from the team is running: shard benchmark, 6 min.');
|
||||
const d = jobNotice(fin('done'), NOW);
|
||||
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
|
||||
assert.equal(d.text, 'A job from the team ran: build, 32 min.');
|
||||
assert.equal(d.key, 'job:done:job-6');
|
||||
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
|
||||
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
|
||||
const f = jobNotice(fin('failed'), NOW);
|
||||
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
|
||||
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
|
||||
assert.equal(f.text, 'A job from the team failed: build, after 32 min. The report did not upload.');
|
||||
assert.match(f.detail, /BUILD FAILED: error\[E0425\]: cannot find value `foo` · exit 1$/);
|
||||
assert.equal(f.tone, 'bad');
|
||||
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
|
||||
// no error line among the results: the summary is the cause
|
||||
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
|
||||
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
|
||||
assert.match(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, /^build: build exited with code 1 · exit 1$/);
|
||||
assert.equal(jobNotice(fin('timeout'), NOW).text, 'A job from the team ran out of time: build, after 32 min. The report did not upload.');
|
||||
assert.equal(N.jobWord('', 'update-now-0313-switch-d937c69d'), 'update check');
|
||||
assert.equal(N.jobWord('restart', 'restart-miners-0312'), 'restart');
|
||||
assert.equal(N.jobWord('collect', ''), 'log collection');
|
||||
assert.equal(N.jobWord('', 'ember-tune-pc1-5'), 'tuning check');
|
||||
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
|
||||
assert.equal(jobNotice(null, NOW), null);
|
||||
});
|
||||
|
|
|
|||
72
app/igneum-app/ui/proof-core.js
Normal file
72
app/igneum-app/ui/proof-core.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/* Igneum proof detail. A data-driven companion, not a mining-progress estimate.
|
||||
* One orbital tile per displayed shard (up to 12). No time-based status promotion.
|
||||
* IgneumProof.mount(canvas).setBlock(observerBlock); destroy() on unmount.
|
||||
*/
|
||||
(function(root){
|
||||
'use strict';
|
||||
var mounted=new WeakMap(),TAU=Math.PI*2;
|
||||
function mount(canvas,opts){
|
||||
opts=opts||{};if(mounted.has(canvas))mounted.get(canvas).destroy();
|
||||
var ctx=canvas.getContext('2d');if(!ctx)throw new Error('A 2D canvas context is required.');
|
||||
var W=0,H=0,dpr=1,block=null,raf=0,disposed=false,paused=false,inView=true,lastPaint=0,frames=0,force=true;
|
||||
var mq=matchMedia('(prefers-reduced-motion: reduce)'),reduce=mq.matches,motion=true,col={},ro,io,mo;
|
||||
var phase=0,lastT=0;
|
||||
function alpha(c,a){if(/^#[a-f\d]{6}$/i.test(c))return 'rgba('+parseInt(c.slice(1,3),16)+','+parseInt(c.slice(3,5),16)+','+parseInt(c.slice(5,7),16)+','+a+')';return c;}
|
||||
function theme(){var s=getComputedStyle(document.documentElement);function c(k,f){return s.getPropertyValue(k).trim()||f;}col={ember:c('--ember','#F2541B'),gold:c('--molten','#FFB35C'),bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),surface:c('--graphite','#16161A'),bg:c('--row','#111114')};kick();}
|
||||
function size(){var nw=canvas.clientWidth,nh=canvas.clientHeight,nd=Math.min(devicePixelRatio||1,2);if(W===nw&&H===nh&&dpr===nd&&canvas.width===Math.round(nw*nd)){kick();return;}W=nw;H=nh;dpr=nd;canvas.width=W*dpr;canvas.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);kick();}
|
||||
function poly(points,fill,stroke){ctx.beginPath();points.forEach(function(p,i){if(i===0)ctx.moveTo(p[0],p[1]);else ctx.lineTo(p[0],p[1]);});ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.stroke();}}
|
||||
function iso(x,y,w,h,depth,fill,stroke){
|
||||
poly([[x-w,y],[x,y+h],[x,y+h+depth],[x-w,y+depth]],alpha(fill,.17),alpha(stroke,.4));
|
||||
poly([[x,y+h],[x+w,y],[x+w,y+depth],[x,y+h+depth]],alpha(fill,.07),alpha(stroke,.32));
|
||||
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],col.bg,stroke);
|
||||
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],alpha(fill,.15),null);
|
||||
}
|
||||
function line(a,b,c,w){ctx.strokeStyle=c;ctx.lineWidth=w||1;ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);ctx.stroke();}
|
||||
function draw(t){
|
||||
if(!W||!H||disposed)return;frames++;
|
||||
if(lastT&&!reduce&&motion&&!paused)phase+=Math.min(t-lastT,70)/1000;lastT=t;
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);
|
||||
var x=W*.5,y=H*.49,scale=Math.min(W/320,H/240),cw=43*scale,ch=23*scale;
|
||||
var shards=block?block.shards||[]:[],active=shards.some(function(s){return s.state==='proving';});
|
||||
var glow=ctx.createRadialGradient(x,y,0,x,y,W*.43);glow.addColorStop(0,alpha(col.ember,.10));glow.addColorStop(1,alpha(col.ember,0));ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);
|
||||
// A stationary coordinate grid, not fabricated hashrate or work counters.
|
||||
for(var k=-3;k<=3;k++){line([x-130*scale,y+k*18*scale-46*scale],[x+130*scale,y+k*18*scale+46*scale],alpha(col.line,.45));line([x-130*scale,y+k*18*scale+46*scale],[x+130*scale,y+k*18*scale-46*scale],alpha(col.line,.45));}
|
||||
ctx.strokeStyle=alpha(col.line,.7);ctx.lineWidth=1;ctx.setLineDash([2,6]);ctx.beginPath();ctx.ellipse(x,y+10*scale,106*scale,59*scale,0,0,TAU);ctx.stroke();ctx.setLineDash([]);
|
||||
var count=Math.min(12,shards.length);
|
||||
for(var i=0;i<count;i++){
|
||||
var angle=-Math.PI/2+i/count*TAU,px=x+Math.cos(angle)*108*scale,py=y+Math.sin(angle)*57*scale;
|
||||
var state=shards[i].state,ink=state==='proving'?col.ember:state==='paid'?col.gold:state==='verified'?col.bone:col.ash;
|
||||
var midx=(px+x)/2,midy=(py+y)/2;
|
||||
ctx.strokeStyle=alpha(ink,state==='planned'?.14:.38);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(px,py);ctx.quadraticCurveTo(midx,midy-12*scale,x,y);ctx.stroke();
|
||||
if(state==='proving'&&!reduce&&motion&&!paused){var p=(phase*.48+i*.33)%1,xx=(1-p)*(1-p)*px+2*(1-p)*p*midx+p*p*x,yy=(1-p)*(1-p)*py+2*(1-p)*p*(midy-12*scale)+p*p*y;ctx.fillStyle=col.gold;ctx.shadowBlur=8;ctx.shadowColor=col.ember;ctx.beginPath();ctx.arc(xx,yy,1.8*scale,0,TAU);ctx.fill();ctx.shadowBlur=0;}
|
||||
iso(px,py,13*scale,7*scale,5*scale,ink,alpha(ink,.72));
|
||||
if(state==='verified'||state==='paid'){ctx.strokeStyle=ink;ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(px-4*scale,py);ctx.lineTo(px-1*scale,py+2*scale);ctx.lineTo(px+4*scale,py-2*scale);ctx.stroke();}
|
||||
else{ctx.fillStyle=alpha(ink,state==='proving'?.9:.4);ctx.fillRect(px-1.5*scale,py-1.5*scale,3*scale,3*scale);}
|
||||
ctx.font='8px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('S'+String(i+1).padStart(2,'0'),px,py+22*scale);
|
||||
}
|
||||
// Layered block core. Geometry does not imply three GPUs or three proofs.
|
||||
var lift=active&&!reduce&&motion&&!paused?Math.sin(phase*1.7)*1.8*scale:0;
|
||||
ctx.lineWidth=1;
|
||||
iso(x,y+21*scale,cw,ch,10*scale,col.ember,alpha(col.ember,.34));
|
||||
iso(x,y+6*scale,cw,ch,8*scale,col.ember,alpha(col.ember,.55));
|
||||
iso(x,y-11*scale-lift,cw,ch,7*scale,col.ember,alpha(col.ember,.85));
|
||||
var topY=y-11*scale-lift;
|
||||
poly([[x,topY-13*scale],[x+25*scale,topY],[x,topY+13*scale],[x-25*scale,topY]],alpha(col.ember,block&&block.proven?.28:.06),alpha(col.gold,.46));
|
||||
if(block&&block.proven){ctx.strokeStyle=col.gold;ctx.lineWidth=1.8;ctx.beginPath();ctx.moveTo(x-7*scale,topY);ctx.lineTo(x-2*scale,topY+4*scale);ctx.lineTo(x+8*scale,topY-4*scale);ctx.stroke();}
|
||||
else{ctx.fillStyle=col.ember;ctx.fillRect(x-2*scale,topY-2*scale,4*scale,4*scale);}
|
||||
if(block&&block.locked){ctx.strokeStyle=col.gold;ctx.lineWidth=1.2;ctx.beginPath();ctx.ellipse(x,y+12*scale,67*scale,38*scale,0,0,TAU);ctx.stroke();ctx.font='8px ui-monospace, monospace';ctx.fillStyle=col.gold;ctx.textAlign='center';ctx.fillText('LOCKED CHECKPOINT',x,H-12);}
|
||||
else if(!block){ctx.font='10px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('SELECT A BLOCK',x,H-12);}
|
||||
else if(shards.length>12){ctx.font='9px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('+ '+(shards.length-12)+' more shards in the list',x,H-12);}
|
||||
}
|
||||
function continuous(){return !disposed&&!document.hidden&&inView&&!reduce&&motion&&!paused&&block&&(block.shards||[]).some(function(s){return s.state==='proving';});}
|
||||
function frame(t){raf=0;if(disposed||document.hidden||!inView)return;if(force||t-lastPaint>=1000/30){draw(t);lastPaint=t;force=false;}if(continuous())raf=requestAnimationFrame(frame);}
|
||||
function kick(){force=true;if(!disposed&&!document.hidden&&inView&&!raf)raf=requestAnimationFrame(frame);}
|
||||
function stop(){if(raf)cancelAnimationFrame(raf);raf=0;lastT=0;}
|
||||
function visibility(){if(document.hidden)stop();else kick();}
|
||||
function reduction(e){reduce=e.matches;stop();kick();}
|
||||
var api={setBlock:function(b){block=b?{hash:b.hash,proven:b.proven===true,locked:b.locked===true,shards:(b.shards||[]).map(function(s){return {state:s.state};})}:null;canvas.setAttribute('aria-label',block?'Block proof detail: '+block.shards.map(function(s,i){return 'shard '+(i+1)+' '+s.state;}).join(', '):'Select a block to inspect its proof shards');kick();},setPaused:function(value){paused=!!value;stop();kick();},setMotion:function(value){motion=!!value;stop();kick();},getStats:function(){return {frames:frames,reducedMotion:reduce||!motion};},destroy:function(){if(disposed)return;disposed=true;stop();ro.disconnect();if(io)io.disconnect();mo.disconnect();root.removeEventListener('resize',size);document.removeEventListener('visibilitychange',visibility);mq.removeEventListener('change',reduction);mounted.delete(canvas);}};
|
||||
ro=new ResizeObserver(size);ro.observe(canvas);if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)kick();else stop();});io.observe(canvas);}mo=new MutationObserver(theme);mo.observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','style','class']});
|
||||
root.addEventListener('resize',size);document.addEventListener('visibilitychange',visibility);mq.addEventListener('change',reduction);mounted.set(canvas,api);theme();size();return api;
|
||||
}
|
||||
root.IgneumProof={mount:mount,version:'2.0.0'};
|
||||
})(window);
|
||||
49
app/igneum-app/ui/ui-ota.test.mjs
Normal file
49
app/igneum-app/ui/ui-ota.test.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// node --test app/igneum-app/ui/ui-ota.test.mjs (no dependencies; the pre-push gate runs it)
|
||||
// The interface over the air (docs/plans/ui-ota.md): Settings > Interface's words and the gentle reload rule.
|
||||
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 src = readFileSync(join(here, 'app.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const V = mod.exports.View;
|
||||
|
||||
test('the embedded interface says built in; an over-the-air one says so with its date', () => {
|
||||
assert.deepEqual(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true }), { line: 'Interface 1.0.0, built in', eyebrow: 'built in', help: '' });
|
||||
const w = V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', installed_at: Date.UTC(2026, 9, 7, 12) / 1000, confirmed: true });
|
||||
assert.equal(w.line, 'Interface 1.0.1, over the air, 7 Oct 2026');
|
||||
assert.equal(w.eyebrow, 'over the air');
|
||||
assert.equal(w.help, 'Built in: 1.0.0.');
|
||||
assert.equal(V.interfaceWords({}).line, '');
|
||||
assert.equal(readFileSync(join(here, 'VERSION'), 'utf8').trim(), '1.0.2', 'the embedded interface version is three-part and in ui/VERSION (1.0.2: the 0.3.22 tree, 7 October 2026)');
|
||||
});
|
||||
|
||||
test('the help line says what is pending, refused, held back by the switch, or skipped', () => {
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', confirmed: false }).help, 'Waiting for this window to confirm the new interface.');
|
||||
assert.ok(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, error: 'the page did not answer within 10 s of loading it.' }).help.startsWith('The last interface over the air was refused:'));
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, builtin: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is published over the air; switch this off to take it.');
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, busy: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is downloading.');
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, note: 'interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19)' }).help, 'Interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19).');
|
||||
});
|
||||
|
||||
test('the page reloads only when the served interface changed and nobody is mid-task', () => {
|
||||
const u = { active_version: '1.0.1' };
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard' }), true);
|
||||
assert.equal(V.shouldReload('1.0.1', u, { phase: 'dashboard' }), false, 'same version: nothing');
|
||||
assert.equal(V.shouldReload('', u, { phase: 'dashboard' }), false, 'a page that never pinged does not reload');
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', typing: true }), false);
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', sheetOpen: true }), false);
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'address' }), false, 'never mid-setup');
|
||||
assert.equal(V.shouldReload('1.0.0', null, { phase: 'dashboard' }), false);
|
||||
});
|
||||
|
||||
test('the page pings health once after its first paint and reports a first-paint error (the DOM block carries both)', () => {
|
||||
assert.ok(src.includes("api('api/ui/health', {})"), 'the health ping');
|
||||
assert.ok(src.includes("api('api/ui/health', { error: uiFirstError })"), 'the first-paint error');
|
||||
assert.ok(src.indexOf("window.addEventListener('error'") < src.indexOf('function poll()'), 'the error listener is installed before the first poll');
|
||||
assert.ok(!/<script[^>]+src=["']https?:/.test(readFileSync(join(here, 'index.html'), 'utf8')), 'no remote script in the page');
|
||||
});
|
||||
|
|
@ -14,10 +14,11 @@ const V = mod.exports.View;
|
|||
|
||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||
|
||||
test('the four sections and their order (miner-ui-3)', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'earnings', 'prove', 'settings']);
|
||||
test('the five sections and their order (miner-ui-4)', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['overview', 'cards', 'earnings', 'prove', 'settings']);
|
||||
assert.equal(V.page('cards').title, 'Cards');
|
||||
assert.equal(V.page('earnings').title, 'Earnings');
|
||||
assert.equal(V.page('nonsense').id, 'mine');
|
||||
assert.equal(V.page('nonsense').id, 'overview');
|
||||
});
|
||||
|
||||
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
||||
|
|
@ -50,6 +51,99 @@ test('a GPU row: temperatures turn amber then red; unknown numbers are empty', (
|
|||
assert.equal(apple.meta, '128 GB unified');
|
||||
});
|
||||
|
||||
test('an Intel Arc card is a discrete Intel row: INTEL badge, no power reading sentence, measure-only tune words (intel-arc, 7 October 2026)', () => {
|
||||
const arc = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'discrete', worker: 'OpenCL', vram_mb: 12208, enabled: true, state: 'mining', hash_now: 15.2, power_w: 0, temp_gpu: 0, sweep_supported: false, sweep_state: 'unsupported', sweep_note: 'not available: Intel Arc exposes no power cap or reading through OpenCL (the driver\'s IGCL counters come next)' });
|
||||
const r = V.cardRow(arc);
|
||||
assert.equal(r.vendor, 'intel');
|
||||
assert.equal(r.kindWord, 'Discrete');
|
||||
assert.equal(r.integrated, false);
|
||||
assert.equal(r.on, true);
|
||||
assert.ok(r.meta.startsWith('12 GB'), r.meta);
|
||||
assert.equal(r.power, '', 'no watts cell without a reading');
|
||||
assert.equal(V.noReading(arc), 'No power or temperature reading on Intel Arc yet: the driver exposes them through IGCL, not OpenCL');
|
||||
assert.equal(V.tunable(arc), false, 'no cap, no clock control: not tunable');
|
||||
assert.equal(V.proveTier(arc), 'Intel(R) Arc(TM) B580 Graphics: cannot prove yet (proving needs an NVIDIA card).');
|
||||
const tm = mod.exports.TuneLine.model(arc, 1000);
|
||||
assert.equal(tm.kind, 'off');
|
||||
assert.equal(tm.text, 'tuning: measure only on Intel Arc once a power reading exists');
|
||||
});
|
||||
|
||||
test('driver-check: the row\'s driver strip per state (offer, running, reboot, error, note, fine)', () => {
|
||||
const offer = { vendor: 'intel', status: 'missing', installed: '', wanted: '32.0.101.9034', min_version: '32.0.101.9034', size: 932631144, url: 'https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe', hash_source: 'https://www.intel.com/content/www/us/en/download/785597/', text: 'Install the Intel Arc driver 32.0.101.9034 (889 MB). No driver was found, so this card cannot mine yet.', installable: true };
|
||||
const arc = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'discrete', worker: 'OpenCL', driver_os: '', driver_offer: offer });
|
||||
const idle = { drivers: { status: 'idle', vendor: '', version: '', progress: 0, message: '', error: '', reboot_required: false, dry_run: false } };
|
||||
let w = V.driverWords(arc, idle);
|
||||
assert.equal(w.kind, 'offer');
|
||||
assert.equal(w.button, 'install');
|
||||
assert.equal(w.text, offer.text);
|
||||
assert.ok(w.sub.startsWith('Downloaded from downloadmirror.intel.com, checked against the vendor'), w.sub);
|
||||
// running: the progress line names the vendor, the version and the percent while downloading
|
||||
w = V.driverWords(arc, { drivers: { status: 'downloading', vendor: 'intel', version: '32.0.101.9034', progress: 0.42, message: 'downloading 32.0.101.9034 (889 MB) from downloadmirror.intel.com', dry_run: false } });
|
||||
assert.equal(w.kind, 'running');
|
||||
assert.equal(w.text, 'Downloading the Intel Arc driver 32.0.101.9034 · 42%');
|
||||
w = V.driverWords(arc, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'installing INTEL 32.0.101.9034: Windows asks for permission once', dry_run: true } });
|
||||
assert.equal(w.text, 'Installing the Intel Arc driver 32.0.101.9034');
|
||||
assert.equal(w.dry, true);
|
||||
// reboot: the Restart now button, mining continues until the click
|
||||
w = V.driverWords(arc, { drivers: { status: 'reboot', vendor: 'intel', version: '32.0.101.9034', reboot_required: true } });
|
||||
assert.equal(w.kind, 'reboot');
|
||||
assert.equal(w.button, 'restart');
|
||||
assert.equal(w.text, 'Intel Arc driver 32.0.101.9034 installed. Restart Windows to finish.');
|
||||
// error: the reason and Install again
|
||||
w = V.driverWords(arc, { drivers: { status: 'error', vendor: 'intel', version: '32.0.101.9034', error: 'sha256 mismatch: the file is not the one the table names' } });
|
||||
assert.equal(w.kind, 'error');
|
||||
assert.equal(w.button, 'install');
|
||||
assert.ok(w.text.startsWith('The Intel Arc driver did not install: sha256 mismatch'), w.text);
|
||||
// another vendor\'s install does not touch this row
|
||||
w = V.driverWords(arc, { drivers: { status: 'downloading', vendor: 'nvidia', version: '617.42', progress: 0.1 } });
|
||||
assert.equal(w.kind, 'offer');
|
||||
// a note (macOS, Linux, HiveOS): no button
|
||||
const mac = card({ vendor: 'apple', kind: 'apple', driver_offer: { vendor: 'apple', status: 'none', text: 'Apple silicon: no driver step, macOS carries it.', installable: false } });
|
||||
w = V.driverWords(mac, idle);
|
||||
assert.equal(w.kind, 'note');
|
||||
assert.equal(w.button, undefined);
|
||||
const linux = card({ driver_offer: { vendor: 'nvidia', status: 'missing', text: 'Linux: install the package nvidia-driver-570 (the worker needs 570.00 or newer).', installable: false } });
|
||||
assert.equal(V.driverWords(linux, idle).kind, 'note');
|
||||
// fine: nothing on the row (the details panel carries the sentence)
|
||||
const fine = card({ driver_os: '617.42', driver_offer: { vendor: 'nvidia', status: 'fine', text: 'NVIDIA driver 617.42: fine.', installable: false } });
|
||||
assert.equal(V.driverWords(fine, idle).kind, 'fine');
|
||||
// no table: nothing
|
||||
assert.equal(V.driverWords(card({ driver_offer: null }), idle).kind, 'none');
|
||||
assert.equal(V.vendorWord('amd'), 'AMD');
|
||||
});
|
||||
|
||||
test('driver-check: an install offer says every card of the vendor stops, and an eGPU card adds the machine warning (PC 2, 7 October 2026; every-card rule 0.3.22)', () => {
|
||||
const offer = { vendor: 'intel', status: 'old', installed: '32.0.101.6733', wanted: '32.0.101.9034', min_version: '32.0.101.9034', size: 932631144, url: 'https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe', hash_source: 'https://www.intel.com/content/www/us/en/download/785597/', text: 'Install the Intel Arc driver 32.0.101.9034, 889 MB (the worker needs 32.0.101.9034 or newer; 32.0.101.6733 is installed)', installable: true };
|
||||
const idle = { drivers: { status: 'idle', vendor: '', version: '', progress: 0, message: '', error: '', reboot_required: false, dry_run: false } };
|
||||
const egpu = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'external', worker: 'OpenCL', enabled: true, state: 'mining', driver_offer: offer });
|
||||
let w = V.driverWords(egpu, idle);
|
||||
assert.equal(w.kind, 'offer');
|
||||
assert.equal(w.external, true);
|
||||
assert.ok(w.sub.includes('The worker on every Intel Arc card stops for the install and comes back after it; the other cards keep mining.'), w.sub);
|
||||
assert.ok(w.sub.includes('a card in an external enclosure can take the machine for a minute and may need a restart'), w.sub);
|
||||
// the same card read as inside the case (PC 2's Arc on 0.3.21 read discrete in its enclosure): the stop sentence stays,
|
||||
// the enclosure warning goes
|
||||
const inside = card({ ...egpu, kind: 'discrete' });
|
||||
w = V.driverWords(inside, idle);
|
||||
assert.equal(w.external, false);
|
||||
assert.ok(w.sub.includes('The worker on every Intel Arc card stops for the install'), w.sub);
|
||||
assert.ok(w.sub.endsWith('The display resets during the install: save your work first.'), w.sub);
|
||||
assert.ok(!w.sub.includes('Mining continues.'));
|
||||
// while it installs, the running line says the vendor's workers are stopped; the eGPU row adds its enclosure
|
||||
w = V.driverWords(egpu, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'installing INTEL 32.0.101.9034: Windows asks for permission once', dry_run: false } });
|
||||
assert.equal(w.kind, 'running');
|
||||
assert.ok(w.sub.includes('The Intel Arc workers are stopped until the installer ends; the display may reset, and this card\u2019s enclosure can take the machine'), w.sub);
|
||||
w = V.driverWords(inside, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'x', dry_run: false } });
|
||||
assert.ok(w.sub.endsWith('The Intel Arc workers are stopped until the installer ends; the display may reset.'), w.sub);
|
||||
});
|
||||
|
||||
test('a card behind a USB4 or Thunderbolt router is an eGPU on the Cards page (PC 2, 7 October 2026)', () => {
|
||||
const r = V.cardRow(card({ key: 'nvidia:1:NVIDIA GeForce RTX 5060 Ti', name: 'NVIDIA GeForce RTX 5060 Ti', vendor: 'nvidia', kind: 'external', enabled: true, state: 'mining', hash_now: 40, power_w: 120, temp_gpu: 55 }));
|
||||
assert.equal(r.kindWord, 'eGPU');
|
||||
assert.equal(r.integrated, false);
|
||||
assert.equal(r.canToggle, true);
|
||||
});
|
||||
|
||||
test('an integrated GPU is shown as integrated and off, with its reason', () => {
|
||||
const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 }));
|
||||
assert.equal(r.integrated, true);
|
||||
|
|
@ -61,7 +155,8 @@ test('an integrated GPU is shown as integrated and off, with its reason', () =>
|
|||
assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory');
|
||||
assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false);
|
||||
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s');
|
||||
assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad');
|
||||
// no fault is permanent (7 October 2026, docs/plans/miner-faults.md): a restarting card is the bad tone, there is no faulted state
|
||||
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 120, message: 'hash rate 0 for 60 s while the node is synced; trying again' })).tone, 'bad');
|
||||
assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node');
|
||||
});
|
||||
|
||||
|
|
@ -72,8 +167,8 @@ test('the big button: start when paused, stop when mining, disabled with no card
|
|||
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
|
||||
assert.equal(none.disabled, true);
|
||||
assert.equal(none.act, '');
|
||||
assert.equal(none.sub, 'switch a GPU on below first');
|
||||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
|
||||
assert.equal(none.sub, 'switch a card on in Cards first');
|
||||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no card this app can use');
|
||||
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
|
||||
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
|
||||
});
|
||||
|
|
@ -106,11 +201,11 @@ test('the next consensus switch is the first height above the DAA score, in plai
|
|||
const next = V.nextSwitch(sw, 140000);
|
||||
assert.equal(next.name, 'Fees v1');
|
||||
assert.equal(next.away, 70000);
|
||||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at block 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||||
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
|
||||
assert.equal(V.nextSwitch(sw, 300000), null);
|
||||
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
|
||||
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
|
||||
assert.match(V.switchLine(null, 300000), /Every planned rule change is behind this node/);
|
||||
assert.match(V.switchLine(null, 0), /^A rule change is planned ahead/);
|
||||
assert.equal(V.nextSwitch([], 5), null);
|
||||
});
|
||||
|
||||
|
|
@ -139,9 +234,9 @@ test('the dev-fee lines name the share and the switch that turns it off', () =>
|
|||
|
||||
test('the remote-jobs line and the helpers', () => {
|
||||
const title = (j) => j.title || j.kind;
|
||||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until the switch above allows remote jobs.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
|
||||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off. Nothing runs here until the switch above allows jobs from the team.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'This build has no jobs address.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running: build.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
|
||||
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
|
||||
assert.equal(V.shortHex('0xabc'), '0xabc');
|
||||
|
|
@ -159,7 +254,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
|
|||
assert.equal(r.word, 'removed');
|
||||
assert.equal(r.tone, 'off');
|
||||
assert.equal(r.hash, '');
|
||||
assert.match(r.sub, /^unplugged; its worker stopped/);
|
||||
assert.match(r.sub, /^unplugged\. Its row goes/);
|
||||
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
|
||||
const b = V.cardRow(bad);
|
||||
assert.equal(b.unusable, true);
|
||||
|
|
@ -194,7 +289,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
|
|||
assert.equal(V.present(bad), false);
|
||||
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
|
||||
assert.equal(t.disabled, true);
|
||||
assert.equal(t.sub, 'no GPU this app can drive');
|
||||
assert.equal(t.sub, 'no card this app can use');
|
||||
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
|
||||
assert.equal(t2.sub, 'mining on 1 of 1 card');
|
||||
});
|
||||
|
|
@ -232,6 +327,7 @@ test('money: £ a day from watts at a price, nothing without one, the cells leav
|
|||
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
|
||||
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
|
||||
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
|
||||
assert.equal(V.wattsTotal([card(), card({ key: 'd', power_w: 48, state: 'off', enabled: false })]), 410.2, 'an idle card’s standby draw is not counted');
|
||||
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
|
||||
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
|
||||
});
|
||||
|
|
@ -249,7 +345,7 @@ test('the tune line: never run, running with a step and a bar, tuned with the po
|
|||
assert.equal(tuned.kind, 'tuned'); assert.equal(tuned.note, '122.3 MH/s at 290 W (0.422 MH/W)'); assert.equal(tuned.button, 'tune');
|
||||
assert.match(tuned.text, /^Tuned 2 h ago · 2470 MHz at 100% · next check /);
|
||||
const prior = V.tuneWords(nv({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'confirm', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 120 }), settings(), NOW);
|
||||
assert.match(prior.text, /^Tuned 2 min ago from the fleet prior, confirmed · 2470 MHz at 100%/);
|
||||
assert.match(prior.text, /^Tuned 2 min ago from what other miners found, confirmed · 2470 MHz at 100%/);
|
||||
const measured = V.tuneWords(nv({ tune_line: 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed' }), settings(), NOW);
|
||||
assert.equal(measured.kind, 'measured'); assert.equal(measured.text, 'Measured 26.7 MH/s at 38 W (0.703 MH/W) as it runs, 10 min ago');
|
||||
assert.equal(measured.note, 'Measured only on Apple silicon: the system sets the clocks and the power; no control exposed');
|
||||
|
|
@ -279,7 +375,7 @@ test('the next check is a weekday inside a week, a date beyond it, due now when
|
|||
test('the Tuning card schedule and the goal consequence', () => {
|
||||
const nv = (over) => card({ sweep_at: 0, tune_steps: 0, ...over });
|
||||
assert.equal(V.schedule([nv()], settings(), NOW), 'Not tuned yet: the first tune starts 2 min into steady mining.');
|
||||
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work by hand.');
|
||||
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work when you press them.');
|
||||
assert.equal(V.schedule([nv({ state: 'tuning', tune_step: 2, tune_steps: 9, tune_eta_s: 500 })], settings(), NOW), 'Tuning now: NVIDIA GeForce RTX 5090, step 2 of 9, mining again in about 8 min.');
|
||||
assert.match(V.schedule([nv({ sweep_at: NOW - 7200 }), nv({ key: 'b', name: 'B', sweep_at: NOW - 60 })], settings(), NOW), /^Last tune 1 min ago \(B\) · next check /);
|
||||
assert.equal(V.schedule([], settings(), NOW), 'No card here can be tuned.');
|
||||
|
|
@ -295,8 +391,10 @@ test('the Tuning card schedule and the goal consequence', () => {
|
|||
test('the prove tier sentence per card, and the counts in one line', () => {
|
||||
assert.equal(V.proveTier(card()), 'NVIDIA GeForce RTX 5090: proves while it mines (32 GB; a full shard needs 20.4 GB).');
|
||||
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 4070', vram_mb: 12288 })), 'NVIDIA GeForce RTX 4070: cannot prove a full shard (12 GB; a full shard needs 20.4 GB).');
|
||||
// main's rule, 7 October 2026: under 12 GB the prover refuses and says why (the same sentence as src/provedefault.rs)
|
||||
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 3080', vram_mb: 10240 })), 'NVIDIA GeForce RTX 3080: proving needs a 12 GB card; mining continues (10 GB).');
|
||||
assert.equal(V.proveTier(card({ name: 'Apple M5 Max', vendor: 'apple' })), 'Apple M5 Max: proves on the CPU, slowly.');
|
||||
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (the prover is CUDA only).');
|
||||
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (proving needs an NVIDIA card).');
|
||||
assert.equal(V.proveLine({ assigned: 0, submitted: 0, paid: 0, paid_wei: '0' }, false, true), 'Off · not proving · 0 assigned · 0 proven · 0 paid · 0.0000 IGN');
|
||||
assert.equal(V.proveLine({ status: 'idle', assigned: 3, submitted: 2, paid: 1, paid_wei: '1500000000000000000', available: true, enabled: true }, true, true), 'Idle · nothing assigned · 3 assigned · 2 proven · 1 paid · 1.5000 IGN');
|
||||
});
|
||||
|
|
@ -310,8 +408,706 @@ test('the node line, the activity line, the asks', () => {
|
|||
assert.equal(V.eventLine('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}'), 'consensus parameters from the signed manifest (details in the log)');
|
||||
assert.equal(V.eventLine('node synced: 151512 blocks, 6 peer(s)'), 'node synced: 151512 blocks, 6 peer(s)');
|
||||
assert.equal(V.eventLine('a '.repeat(100), 40).length <= 41, true);
|
||||
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? The miner restarts.');
|
||||
assert.match(V.trustAsk(true), /^Include proof records the node never checked\? Devnet only; the node restarts\.$/);
|
||||
assert.match(V.trustAsk(false), /^Verify proof records again\?/);
|
||||
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? Mining restarts.');
|
||||
assert.match(V.trustAsk(true), /^Include proofs the node never checked\? Devnet only\. The node restarts\.$/);
|
||||
assert.match(V.trustAsk(false), /^Check proofs again\?/);
|
||||
assert.equal(V.ago(30), 'just now'); assert.equal(V.ago(7200), '2 h ago'); assert.equal(V.ago(200000), '2 d ago');
|
||||
});
|
||||
|
||||
test('the activity feed and the pill speak in the user\'s voice; the engine line is kept as the tooltip', () => {
|
||||
const pe = V.plainEvent('job update-now-0313-switch-d937c69d (Exec restart: re-read the manifest (exec_restart_number 27276) and restart the node with it) done after 0 min, exit 0, report uploaded: update check and install asked');
|
||||
assert.equal(pe.text, 'A job from the team ran: update check, 0 min');
|
||||
assert.match(pe.tech, /^job update-now/);
|
||||
assert.equal(V.plainEvent('updated to Igneum Miner 0.3.14 from 0.3.13').text, 'Updated to 0.3.14');
|
||||
assert.equal(V.plainEvent('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}').text, 'Rule settings read from the team');
|
||||
assert.equal(V.plainEvent('node synced: 151512 blocks, 6 peer(s)').text, 'Node synced: 151,512 blocks, 6 peers');
|
||||
assert.equal(V.plainEvent('block accepted by the node (Apple M5 Max)').text, 'Block found by Apple M5 Max');
|
||||
assert.equal(V.plainEvent('1 remote job received from Igneum').text, '1 job from the team received');
|
||||
assert.equal(V.plainEvent('remote job: update check now; a newer version installs at once').text, 'The team asked for an update check');
|
||||
assert.equal(V.plainEvent('a node already answers on 127.0.0.1:26610; using it (it is not stopped by this app)').text, 'Using the node already running on this machine');
|
||||
assert.equal(V.plainEvent('igneumd exited with code 101 after 300 s; restarting').text, 'The node stopped (code 101). Restarting');
|
||||
assert.equal(V.plainEvent('miner apple:0 exited with code 1; restarting in 8 s').text, 'A card stopped (code 1). Restarting in 8 s');
|
||||
assert.equal(V.plainEvent('NVIDIA GeForce RTX 5090 kernel race: l128w16 at 122.3 MH/s (+4.1% over base, 6 variants, 40 s)').text, 'NVIDIA GeForce RTX 5090: fastest program chosen, 122.3 MH/s');
|
||||
assert.equal(V.plainEvent('block 59199 shard 0 paid 0.0125 IGN').text, 'Proof paid: 0.0125 IGN');
|
||||
assert.equal(V.plainEvent('rewards go to 0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8').text, 'Rewards go to 0xdd44…86e8');
|
||||
assert.equal(V.plainEvent('mining resumed').text, 'Mining resumed');
|
||||
assert.equal(V.plainEvent('something new the engine says').text, 'Something new the engine says');
|
||||
assert.equal(V.plainEvent('something new the engine says').tech, '');
|
||||
const base = { setup_done: true, quitting: false, mining: { state: 'mining', hash_total: 510.6, cards: [] }, node: { state: 'synced' } };
|
||||
assert.deepEqual(V.pill(base), { text: 'Mining · 511 MH/s', tone: 'on' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 17.04, cards: [] } }), { text: 'Mining · 17.0 MH/s', tone: 'on' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'paused', hash_total: 0, cards: [] } }), { text: 'Paused', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'syncing' } }), { text: 'Syncing the node', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'restarting' } }), { text: 'Node restarting', tone: 'bad' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 1, cards: [{ state: 'tuning' }] } }), { text: 'Tuning', tone: 'on' });
|
||||
assert.deepEqual(V.pill({ ...base, setup_done: false, detecting: true }), { text: 'Detecting cards', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, quitting: true }), { text: 'Stopping', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'idle', hash_total: 0, cards: [] } }), { text: 'Not mining', tone: '' });
|
||||
});
|
||||
|
||||
test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving, the sparkline points, the plain label', () => {
|
||||
const curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }];
|
||||
const tuned = card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: NOW - 7200, sweep_watts: 290, sweep_mhs: 122.3, tune_curve: curve, sweep_state: 'idle', sweep_note: '' });
|
||||
const e = V.ember(tuned, settings(), NOW);
|
||||
assert.equal(e.kind, 'tuned'); assert.equal(e.fill, 1);
|
||||
assert.equal(e.label, 'Tuned 2 h ago');
|
||||
assert.match(e.sub, /^2470 MHz at 80% · next check /);
|
||||
assert.equal(e.before, 374); assert.equal(e.after, 290); assert.equal(e.savingW, 84);
|
||||
assert.equal(e.savingText, 'saves 84 W, 0.16% of rate');
|
||||
assert.equal(e.points.length, 4); assert.equal(e.points.filter((p) => p.chosen).length, 1); assert.equal(e.points.filter((p) => p.marked).length, 1);
|
||||
// Ember's own fields win over the curve (tune_before_watts / tune_before_mhs, step 0 of the last full plan)
|
||||
const withFields = V.ember(card({ ...tuned, tune_before_watts: 380, tune_before_mhs: 122.4, tune_source: 'confirm' }), settings(), NOW);
|
||||
assert.equal(withFields.before, 380); assert.equal(withFields.savingW, 90); assert.match(withFields.label, /from what other miners found$/);
|
||||
// a confirm plan without the fields claims no saving (its curve's first row is the prior, not an untuned point)
|
||||
assert.equal(V.ember(card({ ...tuned, tune_source: 'confirm' }), settings(), NOW).savingText, '');
|
||||
assert.equal(V.ember(card({ ...tuned, tune_before_watts: 0 }), settings(), NOW).savingText, '', 'zero means never measured');
|
||||
const running = V.ember(card({ state: 'tuning', tune_step: 4, tune_steps: 9, tune_eta_s: 410, sweep_state: 'running' }), settings(), NOW);
|
||||
assert.equal(running.kind, 'running'); assert.equal(running.fill.toFixed(2), '0.33'); assert.equal(running.label, 'Tuning: step 4 of 9'); assert.equal(running.sub, 'about 7 min left');
|
||||
const measured = V.ember(card({ tune_line: 'Measured: 122.3 MH/s at 410 W (0.298 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)', sweep_state: 'idle' }), settings({ power_control: false }), NOW);
|
||||
assert.equal(measured.kind, 'measured'); assert.equal(measured.label, 'Measured, not tuned'); assert.equal(measured.sub, 'Power control is off'); assert.equal(measured.fill, 0.5);
|
||||
const idle = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings(), NOW);
|
||||
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet'); assert.equal(idle.sub, 'starts after 2 min of steady mining');
|
||||
const off = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings({ sweep: false }), NOW);
|
||||
assert.equal(off.label, 'Not tuned: Ember Tune is off'); assert.equal(off.sub, 'Tune starts one by hand');
|
||||
assert.equal(V.ember(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
|
||||
assert.equal(V.fleetSaving([tuned, card({ key: 'b', sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), 'Ember saves 84 W, £0.56 a day across 1 tuned card');
|
||||
assert.equal(V.fleetSaving([card({ sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), '');
|
||||
assert.deepEqual(V.beforeOf({ tune_before_watts: 0, tune_curve: curve }), null);
|
||||
});
|
||||
|
||||
test('the 0.3.16 engine fields (ember-tune fd03f35): the balance row, the fleet totals, the chain scene note', () => {
|
||||
assert.deepEqual(V.balanceWords({}, NOW), { text: '', sub: '', pounds: null }, 'an older engine has no balance field: the row stays hidden');
|
||||
const unread = V.balanceWords({ balance_wei: null, balance_age_s: -1, balance_note: '', price_gbp_per_ign: null }, NOW);
|
||||
assert.equal(unread.text, 'not read yet'); assert.equal(unread.pounds, null);
|
||||
const read = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 20, balance_note: '', price_gbp_per_ign: null }, NOW);
|
||||
assert.equal(read.text, '12.3456 IGN'); assert.equal(read.sub, 'in the wallet · read just now'); assert.equal(read.pounds, null);
|
||||
const priced = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 90, balance_note: '', price_gbp_per_ign: 0.5 }, NOW);
|
||||
assert.equal(priced.pounds.toFixed(2), '6.17'); assert.equal(priced.sub, 'in the wallet · read 1 min ago');
|
||||
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3, balance_note: 'the node answered 0x for the balance, not a quantity' }, NOW).sub, 'in the wallet · read just now · the node answered 0x for the balance, not a quantity');
|
||||
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3 }, NOW).text, '5,000 IGN');
|
||||
assert.equal(V.ign('1500000000000000000'), 1.5); assert.equal(V.ign('x'), null);
|
||||
const cards = [card(), card({ key: 'b', power_w: 100 })];
|
||||
assert.equal(V.fleetWatts({ watts_total: 0 }, cards), 510.2, 'no engine figure: the UI sums the cards');
|
||||
assert.equal(V.fleetWatts({ watts_total: 629 }, cards), 629, 'the engine figure wins');
|
||||
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 4.23 }, cards, 28), 4.23);
|
||||
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 0 }, cards, 28).toFixed(2), '4.23', 'no engine £: computed from the watts and the price');
|
||||
assert.equal(V.fleetPounds({}, cards, 0), null);
|
||||
assert.equal(V.liveNote({ ok: true, source: 'node', blocks: [] }), 'From your node. Your blocks are ringed.');
|
||||
assert.equal(V.liveNote({ ok: true, source: 'site', blocks: [] }), 'From Igneum’s observer. Your blocks are ringed.');
|
||||
assert.equal(V.liveNote({ ok: true, state: { source: 'site' } }), 'From Igneum’s observer. Your blocks are ringed.', 'the 0.3.15 engine carries the source under state');
|
||||
assert.equal(V.liveNote({ ok: false, pending: true, source: 'site' }), 'connecting');
|
||||
assert.equal(V.liveNote(null), '');
|
||||
const mineCards = [card({ ids: ['8fafda27', '1b2c3d4e'] })];
|
||||
assert.equal(V.isMine('8fafda27', mineCards), true, 'the site: the first 8 hex of the vote key hash');
|
||||
assert.equal(V.isMine('8fafda..e01f22', mineCards), true, 'the 0.3.16 engine: head6..tail6 of the same hash');
|
||||
assert.equal(V.isMine('8fafda27a9c1', mineCards), true, 'a longer prefix of the same hash');
|
||||
assert.equal(V.isMine('0x1B2C3D4E', mineCards), true);
|
||||
assert.equal(V.isMine('8fafdb..e01f22', mineCards), false, 'one hex off');
|
||||
assert.equal(V.isMine('deadbeef', mineCards), false);
|
||||
assert.equal(V.isMine('8faf', mineCards), false, 'under 6 hex never matches');
|
||||
assert.equal(V.isMine('', mineCards), false);
|
||||
});
|
||||
|
||||
test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age is on the node line', () => {
|
||||
const n = (over) => ({ state: 'synced', blocks: 133000, headers: 133000, peers: 1, daa: 140000, last_reading_age_s: 4, tip_age_s: 3, message: '', restart_in_s: 0, ...over });
|
||||
const ok = V.nodeLine(n(), { severity: 'none' }, '');
|
||||
assert.equal(ok.text, 'Node synced · 1 peer · 133,000 blocks · last block 3 s ago'); assert.equal(ok.tone, 'ok');
|
||||
assert.match(ok.sub, /Last block 3 s ago/);
|
||||
const behind = V.nodeLine(n({ state: 'behind', synced: false, tip_age_s: 3180, sync_cause: 'frozen' }), { severity: 'none' }, '');
|
||||
assert.equal(behind.text, 'Node behind · 1 peer · 133,000 blocks · last block 53 min ago'); assert.equal(behind.tone, 'bad');
|
||||
assert.match(behind.sub, /^No new block for 53 min\. The chain may have moved on without this node\./);
|
||||
const none = V.nodeLine(n({ state: 'no peers', synced: false, peers: 0, tip_age_s: 500 }), { severity: 'none' }, '');
|
||||
assert.equal(none.text, 'No peers · 133,000 blocks'.replace('No peers', 'Node no peers')); assert.equal(none.tone, 'bad');
|
||||
assert.match(none.sub, /^No other node on our chain is connected/);
|
||||
// an older node without the age keeps the old words
|
||||
assert.equal(V.nodeLine(n({ tip_age_s: -1 }), { severity: 'none' }, '').text, 'Node synced · 1 peer · 133,000 blocks');
|
||||
assert.equal(V.tipAge(12), '12 s'); assert.equal(V.tipAge(240), '4 min'); assert.equal(V.tipAge(3180), '53 min'); assert.equal(V.tipAge(7300), '2 h');
|
||||
const base = { setup_done: true, quitting: false, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'behind' } };
|
||||
assert.deepEqual(V.pill(base), { text: 'Node behind', tone: 'bad' });
|
||||
assert.deepEqual(V.pill({ ...base, node: { state: 'no peers' } }), { text: 'No peers', tone: 'bad' });
|
||||
assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain');
|
||||
});
|
||||
|
||||
test('not merging (0.3.18 design note): behind with a moving tip, the miner held', () => {
|
||||
const w = V.nodeWords({ state: 'behind', sync_cause: 'not merging', tip_age_s: 3, message: 'behind: your blocks are not merging into the network', blocks: 1, headers: 1, peers: 2 }, { severity: 'none' }, '');
|
||||
assert.equal(w.word, 'behind'); assert.equal(w.tone, 'bad'); assert.match(w.line, /not merging into the network/); assert.doesNotMatch(w.line, /No new block/);
|
||||
});
|
||||
|
||||
test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => {
|
||||
const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' });
|
||||
assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing');
|
||||
assert.equal(/\bfinal\b/i.test(p.note), false);
|
||||
assert.equal(V.finalityWords({ paused: true, last_lock: 0, age_s: 0 }).note, 'Finality paused: under two thirds of the weight is signing');
|
||||
const ok = V.finalityWords({ paused: false, last_lock: 412, age_s: 90 });
|
||||
assert.equal(ok.paused, false); assert.equal(ok.age, '1 min ago'); assert.match(ok.note, /never be undone/);
|
||||
const none = V.finalityWords({ paused: false, last_lock: 0, message: 'waiting for the miner' });
|
||||
assert.equal(none.age, 'n/a'); assert.equal(none.note, 'waiting for the miner');
|
||||
// N5: a node built without the mining engine is a fault
|
||||
assert.equal(V.nodeWords({ state: 'stub', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '').word, 'stub engine');
|
||||
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stub' } }), { text: 'Node fault', tone: 'bad' });
|
||||
});
|
||||
|
||||
test('whose node (publish 2): the app’s own, another node on this machine checked or unchecked, or none when the ports are taken by other rules', () => {
|
||||
assert.deepEqual(V.nodeSource({ source: 'app' }), { text: 'this app’s own node', sub: '' });
|
||||
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'match' }), { text: 'another node on this machine', sub: 'same network and rules, checked' });
|
||||
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'unknown' }), { text: 'another node on this machine', sub: 'its rules could not be checked (an older node)' });
|
||||
assert.deepEqual(V.nodeSource({ source: 'none', port_note: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }), { text: 'no node', sub: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' });
|
||||
assert.deepEqual(V.nodeSource({}), { text: '', sub: '' });
|
||||
// 7 October 2026: the mode is re-decided; after the other node leaves for 60 s the app runs its own and says why
|
||||
assert.deepEqual(V.nodeSource({ source: 'app', mode: 'own', mode_reason: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' }), { text: 'this app’s own node', sub: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' });
|
||||
const gone = V.nodeWords({ state: 'stopped', source: 'external', message: 'the other node on port 26611 went away; this app starts its own node in 45 s unless it comes back', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
|
||||
assert.equal(gone.word, 'stopped'); assert.match(gone.line, /starts its own node in 45 s/);
|
||||
const taken = V.nodeWords({ state: 'stopped', source: 'none', message: 'Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
|
||||
assert.equal(taken.word, 'not started'); assert.equal(taken.tone, 'bad'); assert.match(taken.line, /^Node not started: port 26611 is taken/);
|
||||
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stopped', source: 'none' } }), { text: 'Node not started', tone: 'bad' });
|
||||
});
|
||||
|
||||
// ---- miner-ui-5 (7 October 2026): the miner's first month ----
|
||||
// The chain facts as GET api/ladder shapes them (src/chainfacts.rs) for one key, 6ad0e117 (the example key of the project lead's
|
||||
// chain-scene reference), under the finality params the devnet node reports (api/live params, 7 Oct 2026: dust 5,
|
||||
// weightWindow 7,200 s, presenceWindow 20, checkpointInterval 30; tools/fleet/devnet2-override.json sets none of them,
|
||||
// so Devnet 2 runs the same values). Every number the rungs show is a field of getFinalityWeights,
|
||||
// getFinalityCheckpoints, /api/stats or this app's own ladder.json, and the rung names it on hover.
|
||||
const DN2 = { dust: 5, weight_window: 7200, presence_window: 20, checkpoint_interval: 30, min_daa: 7200 };
|
||||
const MAINNET = { dust: 100, weight_window: 2592000, presence_window: 240, checkpoint_interval: 30, min_daa: 7200 };
|
||||
const chainOf = (over, params) => ({ ok: true, source: 'node', rpc: { weights: 'getFinalityWeights', checkpoints: 'getFinalityCheckpoints', stats: '/api/stats', relay: '' }, params: params || DN2, checkpoint_index: 8495, latest_locked_index: 8495, next_index: 8496, finality_active: true, total_weight: 6000, active_weight: 5900, voters: 14, keys_listed: 4, keys_cap: 0, network: { hashrate_hps: 100e9, miner_ign_per_block: 80, ign_per_block: 100, ramp_factor: 1, daa: 1284117, blocks_per_day_measured: 86400, bps: 1, stale: false }, mine: [], best: null, ...over });
|
||||
const key = (over) => ({ id: '6ad0e117', blocks: 0, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4, ...over });
|
||||
const T5 = 1791362116;
|
||||
const stateOf = (over) => ({ now: T5, uptime_s: 1800, version: '0.3.19', installed_at: T5 - 3000, setup_done: true, address: { display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8' }, settings: { prove: false, dev_fee: true, power_price_pence: 28 }, node: { synced: true }, proving: { paid: 0, paid_wei: 0 }, mining: { state: 'mining', hash_total: 100, accepted_total: 0, accepted_session: 0, found: [], cards: [card({ key: 'nvidia:0:RTX 4070', name: 'NVIDIA GeForce RTX 4070', hash_now: 100, hash_avg: 100, power_w: 150, ids: ['6ad0e117'] })] }, ladder: { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: 0, first_mining_at: 0 }, ...over });
|
||||
const RPC = /getFinalityWeights|getFinalityCheckpoints|igneum_getProofRecords|\/api\/stats|this app/;
|
||||
|
||||
test('the Poisson count-up: the gap from the card’s rate and the network’s, the chance from the time waited', () => {
|
||||
const a = V.poisson(100, 100e9, 0, 1);
|
||||
assert.equal(Math.round(a.gap_s), 1000);
|
||||
assert.equal(a.gapText, '17 minutes');
|
||||
assert.equal(a.chanceText, '0%');
|
||||
assert.equal(a.line, 'A card like yours finds a block about every 17 minutes on today’s network. Chance so far: 0%.');
|
||||
assert.equal(a.pool, '');
|
||||
const b = V.poisson(17, 100e9, 1800, 1);
|
||||
assert.equal(b.gapText, '1.6 hours');
|
||||
assert.equal(b.chanceText, '26%');
|
||||
const c = V.poisson(17, 1e12, 3600, 1);
|
||||
assert.equal(c.gapText, '16 hours');
|
||||
assert.match(c.pool, /^Above an 8-hour gap a pool pays by the share/);
|
||||
assert.equal(V.poisson(100, 100e9, 1000, 1).chanceText, '63%');
|
||||
assert.equal(V.poisson(17, 10e12, 0, 1).gapText, '6.8 days');
|
||||
assert.equal(V.poisson(100, 100e9, 10, 1, true).line.indexOf('Your cards find'), 0);
|
||||
assert.equal(V.poisson(0, 100e9, 10, 1), null);
|
||||
assert.equal(V.poisson(100, 0, 10, 1), null);
|
||||
// IGN a day: 86,400 blocks a day, the share of them, 80 producer points each (the litepaper's 6,912 example)
|
||||
assert.equal(Math.round(V.ignPerDay(100, 100e9, 80, 1)), 6912);
|
||||
assert.equal(V.ignPerDay(100, 0, 80, 1), null);
|
||||
});
|
||||
|
||||
test('a Devnet 2 key walks every rung on screen, and every rung names its RPC field (the gate)', () => {
|
||||
// 0. mining, nothing found: the first rung is "now", the rest wait; the count-up line is on
|
||||
let s = stateOf(), c = chainOf();
|
||||
let r = V.ladderRungs(s, c, T5);
|
||||
assert.deepEqual(r.map((x) => x.id), ['first', 'vote', 'signed', 'window', 'rank', 'streak', 'shard']);
|
||||
assert.deepEqual(r.map((x) => x.state), ['now', 'next', 'next', 'next', 'next', 'next', 'off']);
|
||||
assert.equal(r[0].line, 'Waiting for your first block.');
|
||||
assert.equal(V.currentRung(r).id, 'first');
|
||||
r.forEach((x) => assert.match(x.field, RPC, x.id + ' names no RPC field: ' + x.field));
|
||||
assert.equal(V.blockCard(null, s, c, T5), null);
|
||||
// 1. the first block: the card raises, the key enters the table with one block under the dust line
|
||||
const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 };
|
||||
s = stateOf({ mining: { ...stateOf().mining, accepted_total: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, milestone: m1 } });
|
||||
c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.deepEqual(r.map((x) => x.state), ['done', 'now', 'next', 'now', 'done', 'next', 'off']);
|
||||
assert.equal(r[0].line, 'First block 1 min ago, NVIDIA GeForce RTX 4070.');
|
||||
assert.equal(r[0].sub, '1 block with your key in the window');
|
||||
assert.equal(r[1].line, '1 of 5 blocks to a vote.');
|
||||
assert.equal(r[1].progress, 0.2);
|
||||
assert.equal(r[3].line, '1 of 2 hours.');
|
||||
assert.equal(r[4].line, 'Rank 4 of 4 keys.');
|
||||
assert.equal(V.currentRung(r).id, 'vote');
|
||||
const bc = V.blockCard(m1, s, c, T5);
|
||||
assert.equal(bc.eyebrow, 'your first block');
|
||||
assert.equal(bc.title, 'Block 1,284,117 is yours.');
|
||||
assert.equal(bc.cardLine, 'NVIDIA GeForce RTX 4070 · 100 MH/s · 0.100% of the network');
|
||||
assert.equal(bc.ignLine, '80.00 IGN to 0xdd44…86E8 (72 producer points, 8 for signing)');
|
||||
assert.equal(bc.rankLine, 'rank 4 of 4 keys · hour 1 of 2');
|
||||
assert.equal(bc.url, 'https://igneum.network/block/' + m1.hash);
|
||||
assert.equal(bc.hashShort, '9f3aabab…abc21e');
|
||||
assert.match(bc.fields.ign, /block_reward\.miner_ign/);
|
||||
// 2. five blocks: the vote
|
||||
c = chainOf({ mine: [key({ blocks: 5, voter: true, revealed: true, rank: 4 })], best: key({ blocks: 5, voter: true, revealed: true, rank: 4 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[1].state, 'done');
|
||||
assert.equal(r[1].line, 'Your key has a vote.');
|
||||
assert.equal(r[1].sub, '5 blocks in the window, the line is 5');
|
||||
assert.equal(r[2].state, 'now');
|
||||
assert.equal(r[2].line, 'Signing: the next locked checkpoint carries your vote.');
|
||||
assert.equal(r[5].state, 'now');
|
||||
// 3. the signature in a locked checkpoint, the streak
|
||||
s = stateOf({ ...s, ladder: { ...s.ladder, votes_signed: 31, last_vote_index: 8496, last_vote_at: T5 - 5, last_signed_locked: 8496, signed_locked_count: 30, streak_s: 900 } });
|
||||
c = chainOf({ latest_locked_index: 8496, mine: [key({ blocks: 5, voter: true, participation: 1, rank: 4 })], best: key({ blocks: 5, voter: true, participation: 1, rank: 4 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[2].state, 'done');
|
||||
assert.equal(r[2].line, 'Your signature is in checkpoint 8,496.');
|
||||
assert.equal(r[2].sub, '30 signed and locked · presence 100% · latest lock 8,496');
|
||||
assert.equal(r[5].state, 'done');
|
||||
assert.equal(r[5].line, 'Signing 8 of 80 points, 15 min unbroken.');
|
||||
// 4. the full window: two hours on the devnet; the rank
|
||||
s = stateOf({ ...s, ladder: { ...s.ladder, first_block_at: T5 - 7300, blocks_30d: 61, blocks_today: 61 } });
|
||||
c = chainOf({ latest_locked_index: 8700, mine: [key({ blocks: 61, voter: true, participation: 1, rank: 3 })], best: key({ blocks: 61, voter: true, participation: 1, rank: 3 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[3].state, 'done');
|
||||
assert.equal(r[3].line, '2 of 2 hours. Full weight.');
|
||||
assert.equal(r[4].line, 'Rank 3 of 4 keys.');
|
||||
assert.equal(r[4].sub, '14 keys vote · weight, never hashrate: a card that arrived today sits at the bottom');
|
||||
// P. a paid shard
|
||||
s = stateOf({ ...s, settings: { ...s.settings, prove: true }, proving: { paid: 1, paid_wei: '1150000000000000000' }, ladder: { ...s.ladder, shards: [{ block: 1284117, shard: 3, wei: '1150000000000000000', at: T5 - 30 }] } });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[6].state, 'done');
|
||||
assert.equal(r[6].line, 'Your card proved shard 3 of block 1,284,117.');
|
||||
assert.equal(r[6].sub, '1.15 IGN · 1 paid');
|
||||
assert.deepEqual(r.map((x) => x.state), ['done', 'done', 'done', 'done', 'done', 'done', 'done']);
|
||||
r.forEach((x) => assert.match(x.field, RPC));
|
||||
assert.equal(V.currentRung(r).id, 'shard');
|
||||
// proving off: the rung reads off, never a fault
|
||||
assert.equal(V.ladderRungs(stateOf(), chainOf(), T5)[6].line, 'Proving is off.');
|
||||
});
|
||||
|
||||
test('first-block-21 (the project lead, 7 October 2026): the first-block card is seen once in the app’s life, never on a later run', () => {
|
||||
const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 };
|
||||
const c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) });
|
||||
const afterFirst = (over, ladderOver) => stateOf({ mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 7200, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: true, ...ladderOver }, ...over });
|
||||
const stale = { ...m1, at: T5 - 7200 };
|
||||
// KNOWN-FAILED FIRST: a second run against a 0.3.20 engine (its ladder has no first_block_shown field; the engine up
|
||||
// 30 s, the lifetime count already 1, the card raised two hours ago on the first run and re-sent from its record)
|
||||
// showed "your first block" again before this change
|
||||
let s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { milestone: stale });
|
||||
delete s.ladder.first_block_shown;
|
||||
assert.equal(V.blockCard(stale, s, c, T5), null, 'a second run with the count at 1 shows no first-block card');
|
||||
assert.equal(V.staleCard(stale, s, T5), true);
|
||||
// the first ever block: the card, in the run that raised it, and the body the page reports when it displays it
|
||||
s = stateOf({ started_at: T5 - 1800, mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: false, milestone: m1 } });
|
||||
const bc = V.blockCard(m1, s, c, T5);
|
||||
assert.equal(bc.eyebrow, 'your first block');
|
||||
assert.equal(bc.title, 'Block 1,284,117 is yours.');
|
||||
assert.deepEqual(V.cardSeenBody(bc), { count: 1, at: T5 - 60 });
|
||||
assert.equal(V.cardSeenBody(null), null);
|
||||
assert.equal(V.firstWait(s), false, 'the count-up gives way to the card');
|
||||
// seen once (main's reading): a block found overnight with no window, the engine restarted since (an auto-update):
|
||||
// a 0.3.21 engine keeps the unseen card and the first open shows it, however old it is
|
||||
s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { first_block_shown: false, milestone: stale });
|
||||
assert.equal(V.staleCard(stale, s, T5), false, 'a 0.3.21 engine owns the rule');
|
||||
assert.equal(V.blockCard(stale, s, c, T5).eyebrow, 'your first block');
|
||||
// a second open after it was seen: the engine dropped the card at its start (ladder.rs start_run); the first rung
|
||||
// stays done and the count-up never comes back
|
||||
s = afterFirst({ uptime_s: 12, started_at: T5 - 12 }, { milestone: null });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done');
|
||||
assert.equal(V.firstWait(s), false);
|
||||
// a restart with the count at 1 and the card seen: nothing to show
|
||||
s = afterFirst({ uptime_s: 45, started_at: T5 - 45 }, { milestone: null });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
// an update with the count at 1 from 0.3.20: the engine takes the flag from the old record and sends no card; a
|
||||
// 0.3.20 engine without started_at re-sending its card is refused through uptime_s
|
||||
s = afterFirst({ uptime_s: 45, started_at: T5 - 45, version: '0.3.21' }, { milestone: null });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
s = afterFirst({ uptime_s: 45, started_at: 0 }, { milestone: stale });
|
||||
delete s.ladder.first_block_shown;
|
||||
assert.equal(V.blockCard(stale, s, c, T5), null);
|
||||
// a kept data directory with the count at 0 and the flag unset: the count-up shows, then the first block raises the card
|
||||
s = stateOf({ uptime_s: 300, started_at: T5 - 300, ladder: { ...stateOf().ladder, first_block_shown: false, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40 } });
|
||||
assert.equal(V.firstWait(s), true);
|
||||
assert.equal(V.ladderRungs(s, chainOf(), T5)[0].line, 'Waiting for your first block.');
|
||||
s = stateOf({ uptime_s: 400, started_at: T5 - 400, mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 5] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 5, first_block_shown: false, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40, milestone: { ...m1, at: T5 - 5 } } });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5).eyebrow, 'your first block');
|
||||
assert.equal(V.firstWait(s), false);
|
||||
// a count that started over (settings.json lost, the ladder kept with the flag): no "waiting", no card
|
||||
s = stateOf({ mining: { ...stateOf().mining, accepted_total: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 86400, first_block_shown: true, milestone: null } });
|
||||
assert.equal(V.firstWait(s), false);
|
||||
assert.equal(V.ladderRungs(s, chainOf(), T5)[0].state, 'done');
|
||||
assert.equal(V.blockCard(null, s, c, T5), null);
|
||||
// block 2: the ordinary accepted pulse only (the block on the strip's list, the activity line, the run count), no card
|
||||
s = afterFirst({ uptime_s: 1800, started_at: T5 - 1800, mining: { ...stateOf().mining, accepted_total: 2, accepted_session: 1, found: [T5 - 2] } }, { milestone: null, blocks_30d: 2, blocks_today: 2 });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
assert.equal(V.plainEvent('block accepted by the node (NVIDIA GeForce RTX 4070)').text, 'Block found by NVIDIA GeForce RTX 4070');
|
||||
assert.equal(V.earningsLines(s, c, 28, T5).run.text, '1 block this run');
|
||||
assert.equal(V.earningsLines(s, c, 28, T5).lifetime.text, '2 blocks');
|
||||
assert.deepEqual(s.mining.found, [T5 - 2], 'the strip flashes the block that just arrived');
|
||||
assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done');
|
||||
});
|
||||
|
||||
test('the rungs under mainnet-sized params: 100 blocks to a vote, 30 days to full weight; the relay and the cap are named', () => {
|
||||
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 61, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 61 } });
|
||||
const c = chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64, voters: 1204, mine: [key({ blocks: 61, rank: 41 })], best: key({ blocks: 61, rank: 41 }) }, MAINNET);
|
||||
const r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[1].line, '61 of 100 blocks to a vote.');
|
||||
assert.equal(r[3].line, '23 of 30 days.');
|
||||
assert.equal(r[3].progress, 23 / 30);
|
||||
assert.equal(r[4].line, 'Rank 41 of 64 keys.');
|
||||
assert.equal(r[4].sub, '1,204 keys vote · the top 64 are listed · weight, never hashrate: a card that arrived today sits at the bottom');
|
||||
// a key below the observer's 64 says so instead of inventing a rank
|
||||
const below = V.ladderRungs(s, chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64 }, MAINNET), T5);
|
||||
assert.equal(below[4].line, 'Below the top 64 keys.');
|
||||
assert.equal(below[1].sub, 'your key is not in the table yet');
|
||||
// no chain facts at all: the words say so, nothing is invented
|
||||
const none = V.ladderRungs(s, { ok: false, error: 'neither the node nor the observer answered' }, T5);
|
||||
assert.equal(none[1].line, 'A vote needs the dust line’s blocks in the window.');
|
||||
assert.equal(none[1].sub, 'neither the node nor the observer answered');
|
||||
assert.equal(none[4].line, 'Rank: not read yet.');
|
||||
assert.deepEqual(V.windowWords(2592000), { n: 30, unit: 'days', text: '30 days' });
|
||||
assert.deepEqual(V.windowWords(7200), { n: 2, unit: 'hours', text: '2 hours' });
|
||||
assert.deepEqual(V.windowWords(0), { n: 30, unit: 'days', text: '30 days' });
|
||||
});
|
||||
|
||||
test('the per-card IGN a day cell and the IGN formatter', () => {
|
||||
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), chainOf()), '1,728');
|
||||
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), null), '');
|
||||
assert.equal(V.fmtIgn(0.5), '0.5000 IGN'); assert.equal(V.fmtIgn(4.88044084), '4.88 IGN'); assert.equal(V.fmtIgn(207360), '207,360 IGN');
|
||||
});
|
||||
test('the block card on the four thresholds and on a new card; the first-hour timeline; the profile words', () => {
|
||||
const s = stateOf(), c = chainOf({ mine: [key({ blocks: 100, voter: true, rank: 2 })], best: key({ blocks: 100, voter: true, rank: 2 }) });
|
||||
const m = (kind, count, over) => ({ kind, count, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: 'ab'.repeat(32), daa: 1284117, at: T5 - 10, ...over });
|
||||
assert.equal(V.blockCard(m('hundred', 100), s, c, T5).eyebrow, 'block 100 of this machine');
|
||||
assert.equal(V.blockCard(m('thousand', 1000), s, c, T5).eyebrow, 'block 1,000 of this machine');
|
||||
assert.equal(V.blockCard(m('ten_thousand', 20000), s, c, T5).eyebrow, 'block 20,000 of this machine');
|
||||
assert.equal(V.blockCard(m('card', 7), s, c, T5).eyebrow, 'first block on NVIDIA GeForce RTX 4070');
|
||||
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).title, 'A block is yours.');
|
||||
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).url, '');
|
||||
assert.equal(V.blockCard(m('first', 1), s, chainOf({ network: { ...chainOf().network, ramp_factor: 0.19, miner_ign_per_block: 4.88044084 } }), T5).ignLine, '4.88 IGN to 0xdd44…86E8 (72 producer points, 8 for signing, ramp 19%)');
|
||||
assert.equal(V.blockCard(m('first', 1), s, c, T5).key, 'card:1:' + (T5 - 10));
|
||||
const t = V.timeline(stateOf({ ladder: { ...stateOf().ladder, first_synced_at: T5 - 2800, first_mining_at: T5 - 2700, first_block_at: T5 - 2400 } }), T5);
|
||||
assert.deepEqual(t.steps.map((x) => x.id), ['install', 'started', 'synced', 'mining', 'block', 'payout']);
|
||||
assert.deepEqual(t.steps.map((x) => x.rel), ['+0 s', '+20 min', '+3 min', '+5 min', '+10 min', '+10 min']);
|
||||
assert.match(t.steps[5].note, /coinbase of the first block/);
|
||||
const u = V.timeline(stateOf({ installed_at: 0, uptime_s: 100 }), T5);
|
||||
assert.equal(u.steps[0].note, 'not recorded on this install');
|
||||
assert.equal(u.steps[2].note, 'the moment is not recorded; synced now');
|
||||
assert.match(V.profileWords(true), /^Your address page on igneum\.network is public/);
|
||||
assert.match(V.profileWords(false), /unlisted/);
|
||||
assert.equal(V.gapWords(45), '45 seconds'); assert.equal(V.spanWords(3700), '1 h 1 min'); assert.equal(V.spanWords(3 * 86400), '3 days');
|
||||
// the Activity lines for the milestones
|
||||
assert.equal(V.plainEvent('first block: found by NVIDIA GeForce RTX 4070 (block card)').text, 'Your first block, found by NVIDIA GeForce RTX 4070');
|
||||
assert.equal(V.plainEvent('block 1,000 of this machine: found by RTX 4070 (block card)').text, 'Block 1,000 of this machine, found by RTX 4070');
|
||||
assert.equal(V.plainEvent('first block on Apple M5 Max (block card)').text, 'First block on Apple M5 Max');
|
||||
});
|
||||
|
||||
// ---------- vendor marks (gpu-logos, 7 October 2026) ----------
|
||||
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.css'), 'utf8');
|
||||
const marks = (html) => (html.match(/data-mark="/g) || []).length;
|
||||
|
||||
test('every vendor renders its own mark: one inline SVG glyph per row, tinted by its vendor class, under 2 KB each', () => {
|
||||
const rows = {
|
||||
nvidia: card(),
|
||||
amd: card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', vram_mb: 16384 }),
|
||||
intel: card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', worker: 'OpenCL', vram_mb: 12208 }),
|
||||
apple: card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072 }),
|
||||
};
|
||||
for (const [v, cd] of Object.entries(rows)) {
|
||||
const mk = V.vendorMark(cd);
|
||||
assert.equal(mk.vendor, v);
|
||||
assert.match(mk.svg, /^<svg viewBox="0 0 24 24"/);
|
||||
assert.ok(Buffer.byteLength(mk.svg) < 2048, v + ' glyph is ' + Buffer.byteLength(mk.svg) + ' bytes');
|
||||
assert.ok(!/<image|href=|data:/.test(mk.svg), v + ' glyph is drawn, never a raster or a file');
|
||||
assert.ok(/currentColor/.test(mk.svg), v + ' glyph takes the vendor token through currentColor');
|
||||
const html = V.markHtml(cd);
|
||||
assert.ok(html.startsWith('<div class="badge ' + v + '" data-mark="' + v + '"'), html.slice(0, 60));
|
||||
assert.equal((html.match(/<svg/g) || []).length, 1);
|
||||
}
|
||||
assert.notEqual(V.MARKS.nvidia, V.MARKS.amd); assert.notEqual(V.MARKS.intel, V.MARKS.apple);
|
||||
});
|
||||
|
||||
test('an unknown vendor renders the neutral fallback glyph; a vendor "other" row whose name says Intel keeps the Intel glyph', () => {
|
||||
const mali = card({ key: 'other:0:Mali', name: 'Mali-G78 MP20', vendor: 'other', kind: 'integrated', worker: 'OpenCL', vram_mb: 0 });
|
||||
const mk = V.vendorMark(mali);
|
||||
assert.equal(mk.vendor, 'gpu'); assert.equal(mk.label, 'GPU'); assert.equal(mk.svg, V.MARKS.gpu);
|
||||
assert.ok(V.markHtml(mali).startsWith('<div class="badge gpu" data-mark="gpu"'));
|
||||
assert.equal(V.vendorOf(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'intel');
|
||||
assert.equal(V.vendorOf(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'other', kind: 'integrated' })), 'amd');
|
||||
assert.equal(V.vendorOf({}), 'gpu');
|
||||
});
|
||||
|
||||
test('the mark never appears twice on one row: the head of a first-run row and of a Cards row carries one data-mark, and only View draws a badge', () => {
|
||||
const amd = card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd' });
|
||||
assert.equal(marks(V.rowHead(amd)), 1);
|
||||
assert.equal(marks(V.rowHead(amd, ': removed')), 1);
|
||||
assert.equal(marks(V.markHtml(amd) + V.nameHtml(amd)), 1);
|
||||
assert.equal(marks(V.nameHtml(amd)), 0, 'the name block carries no mark of its own');
|
||||
// the renderers: the first-run rows, the Cards rows and the block card all go through View.markHtml; no other
|
||||
// string in app.js opens a badge
|
||||
assert.equal((src.match(/class="badge /g) || []).length, 1, 'one place in app.js opens a badge');
|
||||
assert.equal((src.match(/View\.markHtml\(/g) || []).length, 3, 'first-run rows, Cards rows, the block card');
|
||||
assert.equal(src.includes('badgeHtml('), false);
|
||||
});
|
||||
|
||||
test('the series line under the name is mono text per generation; an integrated row (Intel iGPU, Apple) ends in "integrated" and keeps one mark', () => {
|
||||
assert.equal(V.seriesLine(card()), 'RTX 50 series · Blackwell');
|
||||
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 4070' })), 'RTX 40 series · Ada Lovelace');
|
||||
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 3080' })), 'RTX 30 series · Ampere');
|
||||
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce GTX 1660 Super' })), 'GTX 16 series · Turing');
|
||||
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd' })), 'RX 9000 series · RDNA 4');
|
||||
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 7800 XT', vendor: 'amd' })), 'RX 7000 series · RDNA 3');
|
||||
assert.equal(V.seriesLine(card({ name: 'gfx1201', code: 'gfx1201', vendor: 'amd' })), 'RDNA 4');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel' })), 'Arc B series · Battlemage');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel Arc A770', vendor: 'intel' })), 'Arc A series · Alchemist');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'UHD Graphics · integrated');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel Iris Xe Graphics', vendor: 'other', kind: 'integrated' })), 'Iris Xe · integrated');
|
||||
assert.equal(V.seriesLine(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'amd', kind: 'integrated' })), 'Radeon · integrated');
|
||||
assert.equal(V.seriesLine(card({ name: 'Apple M5 Max', vendor: 'apple', kind: 'apple' })), 'M5 series · integrated');
|
||||
const igpu = card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' });
|
||||
const head = V.rowHead(igpu);
|
||||
assert.equal(marks(head), 1); assert.match(head, /data-mark="intel"/); assert.match(head, /<div class="gen">UHD Graphics · integrated<\/div>/);
|
||||
assert.equal(V.vendorMark(igpu).svg, V.MARKS.intel, 'an integrated Intel row keeps the Intel glyph, never a second mark');
|
||||
assert.equal(V.nameHtml(card({ name: '<b>x</b>', vendor: 'zzz', kind: 'unknown' })).includes('<b>'), false, 'the name is escaped');
|
||||
});
|
||||
|
||||
test('the light theme contrast ratio of every vendor colour on its well is at least 3:1, and app.css carries the same hex values and wells', () => {
|
||||
for (const v of Object.keys(V.VENDORS)) {
|
||||
const light = V.vendorContrast(v, 'light'), dark = V.vendorContrast(v, 'dark');
|
||||
assert.ok(light >= 3, v + ' light: ' + light.toFixed(2) + ':1 on its well');
|
||||
assert.ok(dark >= 3, v + ' dark: ' + dark.toFixed(2) + ':1 on its well');
|
||||
const [r, g, b] = V.hexRgb(V.VENDORS[v].light), [dr, dg, db] = V.hexRgb(V.VENDORS[v].dark);
|
||||
assert.ok(css.includes('--mark-' + v + ':' + V.VENDORS[v].dark + ';'), v + ' dark hex in app.css');
|
||||
assert.equal((css.match(new RegExp('--mark-' + v + ':' + V.VENDORS[v].light + ';', 'g')) || []).length, 2, v + ' light hex in both light blocks');
|
||||
assert.ok(css.includes('--mark-' + v + '-well:rgba(' + dr + ',' + dg + ',' + db + ',' + String(V.WELL_ALPHA_DARK).replace(/^0/, '') + ')'), v + ' dark well');
|
||||
assert.equal((css.match(new RegExp('--mark-' + v + '-well:rgba\\(' + r + ',' + g + ',' + b + ',' + String(V.WELL_ALPHA_LIGHT).replace(/^0/, '') + '\\)', 'g')) || []).length, 2, v + ' light well in both light blocks');
|
||||
}
|
||||
// the ember accent is for state, never a brand
|
||||
for (const v of Object.keys(V.VENDORS)) { assert.notEqual(V.VENDORS[v].dark, '#F2541B'); assert.notEqual(V.VENDORS[v].light, '#E04A14'); }
|
||||
assert.equal(/\.badge\.(nvidia|amd|intel|apple|gpu)\{[^}]*var\(--ember/.test(css), false, 'no badge rule tints with ember');
|
||||
assert.equal(/\.badge[^{]*\{[^}]*animation/.test(css), false, 'nothing on the mark animates');
|
||||
});
|
||||
|
||||
// ---------- the custom switch (the Prove page fix, 7 October 2026) ----------
|
||||
// the project lead's screenshot from the live 0.3.19 Mac app: a thin white rectangle above-left of the "Prove on this machine"
|
||||
// switch and the knob drawn outside the track's left edge at off. Two generic classes reached the switch (the DAG
|
||||
// legend swatch .lg hit label.switch.lg, the shard track .track hit .switch .track) and the native input was an
|
||||
// unpositioned absolute. This test reads app.css: no bare .lg or .track rule may exist (every use is scoped), the
|
||||
// native input is hidden the accessible way (1 px clip, still focusable), and the knob's box sits inside the track's
|
||||
// box at off and at on for both sizes, in the base rules and untouched by both theme blocks.
|
||||
const cssRules = (text) => { const out = []; const re = /([^{}]+)\{([^{}]*)\}/g; let m; text = text.replace(/\/\*[\s\S]*?\*\//g, ''); while ((m = re.exec(text))) out.push({ sel: m[1].trim(), body: m[2] }); return out; };
|
||||
const ruleOf = (sel) => cssRules(css).filter((r) => r.sel.split(',').map((x) => x.trim()).includes(sel)).map((r) => r.body).join(';');
|
||||
const px = (body, prop) => { const m = new RegExp('(?:^|;)\\s*' + prop + ':\\s*(-?[\\d.]+)px').exec(body); return m ? parseFloat(m[1]) : null; };
|
||||
const translate = (body) => { const m = /transform:\s*translateX\((-?[\d.]+)px\)/.exec(body); return m ? parseFloat(m[1]) : 0; };
|
||||
|
||||
test('no generic .lg or .track rule reaches a custom switch: every use of those class names is scoped (known-failed on the 0.3.19 CSS)', () => {
|
||||
const bare = cssRules(css).flatMap((r) => r.sel.split(',').map((x) => x.trim())).filter((sel) => /^\.(lg|track)(\.[\w-]+)*(\s+[\w.:-]+)*$/.test(sel) && !/^\.switch\b/.test(sel));
|
||||
assert.deepEqual(bare, [], 'selectors that start with a bare .lg or .track: ' + bare.join(' | '));
|
||||
assert.equal(/<label class="switch lg"/.test(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8')), false, 'the Prove switch no longer shares the legend swatch class');
|
||||
});
|
||||
|
||||
test('the native input of every custom switch is hidden the accessible way: 1 px clipped, still in the tree and focusable, the label kept', () => {
|
||||
const sw = ruleOf('.switch'), input = ruleOf('.switch input');
|
||||
assert.match(sw, /position:\s*relative/, '.switch is the positioned ancestor of its input');
|
||||
assert.match(input, /position:\s*absolute/);
|
||||
assert.equal(px(input, 'width'), 1); assert.equal(px(input, 'height'), 1);
|
||||
assert.match(input, /clip:\s*rect\(0 0 0 0\)|clip-path:\s*inset\(50%\)/, 'the clip pattern');
|
||||
assert.match(input, /overflow:\s*hidden/);
|
||||
assert.equal(/display:\s*none|visibility:\s*hidden/.test(input), false, 'display none would take the input out of the tab order');
|
||||
assert.match(ruleOf('.switch input:focus-visible+.track'), /outline/, 'focus stays visible on the track');
|
||||
});
|
||||
|
||||
test('the knob sits inside the track at off and at on, both sizes, and neither theme block touches the switch geometry', () => {
|
||||
const track = ruleOf('.switch .track'), knob = ruleOf('.switch .track::after'), on = ruleOf('.switch input:checked+.track::after');
|
||||
const big = ruleOf('.switch.big .track'), bigKnob = ruleOf('.switch.big .track::after'), bigOn = ruleOf('.switch.big input:checked+.track::after');
|
||||
const inside = (tw, th, kw, kh, left, top, dx, label) => {
|
||||
assert.ok(left >= 0 && top >= 0, label + ': the knob starts inside (left ' + left + ', top ' + top + ')');
|
||||
assert.ok(left + kw <= tw && top + kh <= th, label + ' off: knob ' + kw + 'x' + kh + ' at ' + left + ',' + top + ' in a ' + tw + 'x' + th + ' track');
|
||||
assert.ok(left + dx + kw <= tw, label + ' on: knob right edge ' + (left + dx + kw) + ' in a ' + tw + ' track');
|
||||
assert.ok(left + dx >= 0, label + ' on: knob left edge ' + (left + dx));
|
||||
};
|
||||
assert.match(track, /position:\s*relative/, 'the track positions its knob');
|
||||
inside(px(track, 'width'), px(track, 'height'), px(knob, 'width'), px(knob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(on), 'the 40 px switch');
|
||||
inside(px(big, 'width'), px(big, 'height'), px(bigKnob, 'width'), px(bigKnob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(bigOn), 'the big switch');
|
||||
// the two theme blocks (prefers-color-scheme light, data-theme light) only set tokens: no .switch or .track rule inside them
|
||||
for (const m of css.matchAll(/@media \(prefers-color-scheme:light\)\{:root:not\(\[data-theme="dark"\]\)\{[^}]*\}\}|:root\[data-theme="light"\]\{[^}]*\}/g)) assert.equal(/\.switch|\.track/.test(m[0]), false, 'a theme block touches the switch');
|
||||
assert.equal(cssRules(css).some((r) => /\.switch|\.track/.test(r.sel) && /@media \(prefers-color-scheme/.test(r.sel)), false);
|
||||
});
|
||||
|
||||
// ---------- the shared marks module (brand/marks/vendor-marks.mjs, for the site) ----------
|
||||
test('brand/marks/vendor-marks.mjs carries the app\'s marks and tokens verbatim, plus the Windows mark in the same treatment', async () => {
|
||||
const mod = await import(join(dirname(fileURLToPath(import.meta.url)), '../../../brand/marks/vendor-marks.mjs'));
|
||||
assert.deepEqual(mod.MARKS, V.MARKS, 'the glyph strings (regenerate with node brand/marks/regen.mjs)');
|
||||
assert.deepEqual(mod.VENDORS, V.VENDORS);
|
||||
assert.deepEqual(mod.WELL_ALPHA, { dark: V.WELL_ALPHA_DARK, light: V.WELL_ALPHA_LIGHT });
|
||||
assert.equal(mod.OS_MARKS.macos, V.MARKS.apple);
|
||||
assert.match(mod.OS_MARKS.windows, /^<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path /);
|
||||
assert.ok(Buffer.byteLength(mod.OS_MARKS.windows) < 2048);
|
||||
assert.equal(mod.markHtml('amd'), V.markHtml({ vendor: 'amd' }));
|
||||
assert.equal(mod.markHtml('zzz'), V.markHtml({ vendor: 'zzz' }));
|
||||
// the tokens the module prints are the ones app.css carries
|
||||
for (const line of mod.tokensCss().split('\n')) { const inner = /\{([^{}]*)\}\}?$/.exec(line)[1]; assert.ok(css.includes(inner), 'app.css carries: ' + inner.slice(0, 60)); }
|
||||
});
|
||||
|
||||
// ---------- the Earnings card, tidied (the project lead, 7 October 2026: "remove all the type a price stuff") ----------
|
||||
const indexHtml = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8');
|
||||
const earningsCard = () => { const a = indexHtml.indexOf('id="page-earnings"'); return indexHtml.slice(a, indexHtml.indexOf('id="ladder-card"', a)); };
|
||||
|
||||
test('no price input exists: no £ per IGN field, no "Use it", no remembered price, no money line derived from a typed price (known-failed on 0.3.20)', () => {
|
||||
const ec = earningsCard();
|
||||
assert.equal(/e-price|Type a price|Use it|price-row|per IGN/.test(ec), false, 'the markup');
|
||||
assert.equal(/<input[^>]*type="number"/.test(ec), false, 'no number input on the card');
|
||||
assert.equal(/ign_price|ignPrice|saveIgnPrice|e-price/.test(src), false, 'the setting and its handlers');
|
||||
const e = V.earningsLines(stateOf(), chainOf(), 28, T5);
|
||||
assert.equal('price' in e, false); assert.equal(V.FIELDS.price, undefined);
|
||||
for (const k of Object.keys(e)) assert.equal(/£|GBP|pounds?\b/.test((e[k] && e[k].text) || ''), k === 'cost', k + ' carries money only on the electricity cell');
|
||||
});
|
||||
|
||||
test('the headline reads the day rate with its reason line, then blocks and IGN this run', () => {
|
||||
const found = [T5 - 3400, T5 - 2500, T5 - 1300, T5 - 200];
|
||||
const s = stateOf({ uptime_s: 5400, mining: { ...stateOf().mining, accepted_total: 2592, accepted_session: 140, found: found, watts_total: 150 }, proving: { paid: 14, paid_wei: '16100000000000000000' }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 2592 } });
|
||||
const c = chainOf({ source: 'observer', keys_listed: 1204, keys_cap: 64, voters: 1204, mine: [key({ blocks: 2592, voter: true, rank: 41 })], best: key({ blocks: 2592, voter: true, rank: 41 }) }, MAINNET);
|
||||
const e = V.earningsLines(s, c, 28, T5);
|
||||
assert.equal(e.day.text, '7,680 IGN a day');
|
||||
assert.equal(e.day.sub, 'at your last hour’s rate: 4 blocks at 80.00 IGN each · about 6,912 IGN expected at 100 MH/s');
|
||||
assert.equal(e.run.text, '140 blocks this run');
|
||||
assert.equal(e.run.sub, '11,200 IGN at today’s reward · 14 shards, 16.10 IGN · 1 h 30 min');
|
||||
const f = V.earningsLines(stateOf(), chainOf(), 28, T5);
|
||||
assert.equal(f.day.text, 'about 6,912 IGN a day');
|
||||
assert.equal(f.day.sub, 'expected at 100 MH/s, 0.100% of today’s network · no block in the last hour yet');
|
||||
const n = V.earningsLines(stateOf(), { ok: false, error: 'neither answered' }, 28, T5);
|
||||
assert.equal(n.day.text, 'IGN a day: reading the network'); assert.equal(n.day.sub, 'neither answered');
|
||||
// the markup: the headline is the first thing on the card, the run line second
|
||||
const ec = earningsCard();
|
||||
assert.ok(ec.indexOf('id="e-day"') < ec.indexOf('id="e-run"') && ec.indexOf('id="e-run"') < ec.indexOf('class="earn-three"'), 'headline, run line, row of three, in that order');
|
||||
assert.ok(ec.indexOf('class="earn-three"') < ec.indexOf('id="s-devfee"'), 'the dev-fee switch is last');
|
||||
assert.equal(/id="r-devfee-line"/.test(ec), false, 'one sentence on the dev fee, no second help line');
|
||||
for (const line of [e.day.sub, e.run.sub, f.day.sub]) assert.equal(/—|–/.test(line), false, 'no dashes: ' + line);
|
||||
});
|
||||
|
||||
test('the row of three renders: weight rung, electricity and lifetime, each with a quiet line', () => {
|
||||
const found = [T5 - 3400, T5 - 2500, T5 - 1300, T5 - 200];
|
||||
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 2592, accepted_session: 140, found: found, watts_total: 150 }, proving: { paid: 14, paid_wei: '16100000000000000000' }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 2592 } });
|
||||
const c = chainOf({ source: 'observer', keys_listed: 1204, keys_cap: 64, voters: 1204, mine: [key({ blocks: 2592, voter: true, rank: 41 })], best: key({ blocks: 2592, voter: true, rank: 41 }) }, MAINNET);
|
||||
const e = V.earningsLines(s, c, 28, T5);
|
||||
assert.equal(e.weight.text, 'rank 41 of 1,204 keys');
|
||||
assert.equal(e.weight.sub, '2,592 blocks in 30 days · 23 of 30 days · 0.100% of the network');
|
||||
assert.equal(e.cost.text, '£1.01 a day');
|
||||
assert.equal(e.cost.sub, 'at 28p/kWh for 150 W · 2,133 IGN per kWh');
|
||||
assert.equal(e.lifetime.text, '2,592 blocks');
|
||||
assert.equal(e.lifetime.sub, '207,360 IGN at today’s reward');
|
||||
const noPrice = V.earningsLines(s, c, 0, T5);
|
||||
assert.equal(noPrice.cost.text, '150 W'); assert.equal(noPrice.cost.sub, 'set a price in Settings · 2,133 IGN per kWh');
|
||||
const fresh = V.earningsLines(stateOf(), chainOf(), 28, T5);
|
||||
assert.equal(fresh.weight.text, 'not in the weight table yet');
|
||||
assert.equal(fresh.lifetime.text, '0 blocks');
|
||||
assert.equal(V.earningsLines(stateOf({ mining: { ...stateOf().mining, watts_total: 0, cards: [] } }), chainOf(), 28, T5).cost.sub, 'no card reports its draw');
|
||||
assert.equal(e.devFee(true, 12), 'Dev fee: 1 block in 100 pays the people who make this app (12 blocks so far).');
|
||||
assert.equal(e.devFee(false, 0), 'Dev fee off. Every block pays your address.');
|
||||
const ec = earningsCard(), three = ec.slice(ec.indexOf('class="earn-three"'), ec.indexOf('id="s-devfee"'));
|
||||
assert.equal((three.match(/class="earn-cell"/g) || []).length, 3);
|
||||
for (const id of ['e-rung', 'e-cost', 'e-blocks']) assert.ok(three.includes('id="' + id + '"'), id);
|
||||
assert.ok(/\.earn-three\{[^}]*grid-template-columns:\s*repeat\(3/.test(css), 'three columns in app.css');
|
||||
});
|
||||
|
||||
test('the "Prove instead of mining" row shows only on an under-12 GB machine and names the choice (main, 7 October 2026)', () => {
|
||||
assert.equal(V.proveInsteadWords({ under_12gb: false }, { prove_instead: false }).show, false);
|
||||
const off = V.proveInsteadWords({ under_12gb: true }, { prove_instead: false });
|
||||
assert.equal(off.show, true); assert.equal(off.on, false); assert.match(off.help, /never both/);
|
||||
const on = V.proveInsteadWords({ under_12gb: true }, { prove_instead: true });
|
||||
assert.equal(on.show, true); assert.equal(on.on, true); assert.match(on.help, /held off/);
|
||||
});
|
||||
|
||||
// ---------- currency (currency-21, the project lead, 7 October 2026: "£/day is for UK; we need other currencies") ----------
|
||||
// No IP lookups: the OS locale's region picks the currency and the default tariff at first run; a Settings override
|
||||
// (currency plus the price per kWh in it) wins and lives in the engine's settings file; every money line goes through
|
||||
// one Intl formatter; the user's own tariff is the number, so no exchange rate exists anywhere.
|
||||
const uiDir = dirname(fileURLToPath(import.meta.url));
|
||||
const pageHtml = readFileSync(join(uiDir, 'index.html'), 'utf8');
|
||||
const rustSrc = (f) => readFileSync(join(uiDir, '../src/' + f), 'utf8');
|
||||
|
||||
test('a de-DE machine reads EUR with the EU tariff from its locale, before any choice is made (known-failed on 0.3.21)', () => {
|
||||
const ch = V.currencyChoice({ region: '', currency: '' }, 'de-DE');
|
||||
assert.equal(ch.code, 'EUR'); assert.equal(ch.region, 'de'); assert.equal(ch.source, 'locale'); assert.equal(ch.prompt, false);
|
||||
assert.equal(V.regionOf('de').price, 38.69); assert.match(V.regionOf('de').source, /Eurostat/);
|
||||
assert.equal(V.regionFromLocale('de-DE'), 'de'); assert.equal(V.regionFromLocale('en-GB'), 'gb'); assert.equal(V.regionFromLocale('de'), 'de'); assert.equal(V.regionFromLocale('fr-CA'), 'ca'); assert.equal(V.regionFromLocale(''), '');
|
||||
const ca = V.currencyChoice({}, 'fr-CA'); assert.equal(ca.code, 'CAD'); assert.equal(V.regionOf('ca').name, 'Canada'); assert.equal(V.regionOf('ca').cur, 'CAD');
|
||||
assert.equal(V.currencyChoice({}, 'en-AU').code, 'AUD');
|
||||
assert.equal(V.currencyChoice({}, 'fr-FR').code, 'EUR'); assert.ok(V.regionOf('fr').price > 0, 'an EU member without its own row takes the EU average'); assert.match(V.regionOf('fr').source, /EU average/);
|
||||
assert.equal(V.currencyChoice({}, 'sv-SE').code, 'SEK'); assert.equal(V.regionOf('se').price, 0, 'no table price outside the euro: the miner types one');
|
||||
// one formatter, the symbol placed as the locale places it, no hand-built money string left
|
||||
assert.equal(V.fmtMoney(1.5, 'EUR', 'de-DE'), '1,50 €'); assert.equal(V.fmtMoney(1.5, 'EUR', 'en-IE'), '€1.50');
|
||||
assert.equal(V.fmtMoney(1234.5, 'USD', 'en-US'), '$1,235'); assert.equal(V.fmtMoney(1.5, 'CAD', 'en-CA'), '$1.50'); assert.equal(V.fmtMoney(1.5, 'JPY', 'en-US'), '¥2'); assert.equal(V.fmtMoney(null, 'GBP', 'en-GB'), '');
|
||||
V.setLocale('de-DE'); V.setRegion('de'); assert.equal(V.money(1.5), '1,50 €'); V.setLocale('en-GB'); V.setRegion('');
|
||||
assert.equal((src.match(/currency\.symbol \+ \(/g) || []).length, 0, 'money() no longer concatenates a symbol');
|
||||
});
|
||||
|
||||
test('a user override wins over the locale and the region, and survives restart through the engine settings file', () => {
|
||||
const ch = V.currencyChoice({ region: 'gb', currency: 'USD' }, 'de-DE');
|
||||
assert.equal(ch.code, 'USD'); assert.equal(ch.source, 'override'); assert.equal(ch.prompt, false);
|
||||
assert.equal(V.currencyChoice({ region: 'gb', currency: '' }, 'de-DE').code, 'GBP', 'a chosen region beats the locale');
|
||||
assert.equal(V.currencyChoice({ region: 'gb', currency: 'zzz' }, 'en-GB').code, 'GBP', 'an unknown override is ignored');
|
||||
V.setLocale('en-GB'); V.setCurrency('USD'); assert.equal(V.money(1.5), '$1.50'); assert.equal(V.currencyNow().code, 'USD'); V.setCurrency(''); V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50'); V.setRegion('');
|
||||
// survives restart: a field of the engine's settings file, carried in the state the UI reads, set through api/region
|
||||
assert.match(rustSrc('config.rs'), /pub currency: String/); assert.match(rustSrc('state.rs'), /pub currency: String/);
|
||||
assert.match(rustSrc('server.rs'), /body\.get\("currency"\)/); assert.match(rustSrc('engine.rs'), /Cmd::Region\(region, price, currency\)/);
|
||||
assert.match(rustSrc('config.rs'), /fn currency_survives_a_round_trip|currency_round_trip/, 'the Rust round-trip test exists');
|
||||
// the Settings card: a currency picker with "follow the region" first; the price unit follows the pick
|
||||
assert.ok(pageHtml.includes('id="s-currency"'), 'the picker');
|
||||
assert.deepEqual(V.minorOf('GBP'), { word: 'pence', abbr: 'p', digits: 2 }); assert.equal(V.minorOf('EUR').word, 'euro cents'); assert.equal(V.minorOf('CAD').abbr, 'c'); assert.equal(V.minorOf('JPY').digits, 0); assert.equal(V.minorOf('JPY').word, 'yen');
|
||||
assert.ok(V.CURRENCY_CODES.indexOf('GBP') === 0 && V.CURRENCY_CODES.slice(0, 5).join() === 'GBP,EUR,USD,CAD,AUD' && V.CURRENCY_CODES.length > 20, 'the five first, then the long tail');
|
||||
});
|
||||
|
||||
test('a locale with no currency row falls back to USD with the override prompt visible', () => {
|
||||
const eo = V.currencyChoice({}, 'eo');
|
||||
assert.equal(eo.code, 'USD'); assert.equal(eo.prompt, true); assert.equal(eo.source, 'fallback');
|
||||
const aq = V.currencyChoice({}, 'en-AQ'); assert.equal(aq.code, 'USD'); assert.equal(aq.prompt, true);
|
||||
assert.equal(V.currencyChoice({}, 'en-GB').prompt, false);
|
||||
assert.equal(V.currencyChoice({ region: 'other', currency: '' }, 'eo').prompt, true, '"Somewhere else" with no currency still asks');
|
||||
assert.equal(V.currencyChoice({ region: 'other', currency: 'USD' }, 'eo').prompt, false, 'an answer closes the prompt');
|
||||
assert.match(V.currencyAsk(eo), /could not tell your currency/);
|
||||
assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), '');
|
||||
assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step');
|
||||
});
|
||||
|
||||
// ---------- the two check buttons on Settings (7 October 2026, main's ruling after a "Check now" pressed for the wrong check) ----------
|
||||
// The update check and the remote-jobs check sat as "Check" and "Check now"; a reader (and a lane) took the jobs one for
|
||||
// the update one. Each button now names its check, and each handler posts its own route.
|
||||
test('Settings: "Check for an update" posts api/update/check and "Check for jobs" posts api/jobs/check (known-failed on the old labels)', () => {
|
||||
const html = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8');
|
||||
assert.match(html, /<button class="btn small" id="s-update">Check for an update<\/button>/, 'the update button names its check');
|
||||
assert.match(html, /<button class="btn tiny ghost" id="s-jobs-check">Check for jobs<\/button>/, 'the jobs button names its check');
|
||||
assert.equal((html.match(/>Check now</g) || []).length, 0, 'no bare "Check now" left on the page');
|
||||
const upd = /\$\('s-update'\)\.addEventListener\('click', function \(\) \{ api\('api\/update\/check', \{\}\);/.exec(src);
|
||||
const jobs = /\$\('s-jobs-check'\)\.addEventListener\('click', function \(\) \{ api\('api\/jobs\/check', \{\}\);/.exec(src);
|
||||
assert.ok(upd, 'the update button posts api/update/check'); assert.ok(jobs, 'the jobs button posts api/jobs/check');
|
||||
assert.equal(/\$\('s-update'\)[^\n]*api\/jobs\/check/.test(src), false); assert.equal(/\$\('s-jobs-check'\)[^\n]*api\/update\/check/.test(src), false);});
|
||||
|
||||
// ---------- the network step (0.3.23, main's design with two corrections, 7 October 2026) ----------
|
||||
// A fourth first-run screen and a Settings card: two cards, Devnet 3 and igneum-testnet-1. The fresh-install default comes
|
||||
// from the manifest's default_network (Devnet 3 until the go); the testnet card reads "not yet open" and is refused until the
|
||||
// manifest names it open; an existing install keeps its network; nothing switches a running miner without the user's click
|
||||
// and the confirm that names what resets.
|
||||
test('network words: the manifest default selects the card on a fresh install, the testnet card is refused until open, an existing install keeps its network (known-failed on 0.3.23)', () => {
|
||||
const fresh = { setup_done: false, network_name: 'igneum-devnet-3', network_pending: '', settings: { network: '' }, update: { default_network: 'devnet-3', testnet_open: false } };
|
||||
const w = V.networkWords(fresh);
|
||||
assert.deepEqual(w.cards.map((c) => c.id), ['devnet-3', 'testnet-1']);
|
||||
assert.equal(w.selected, 'devnet-3', 'the manifest default');
|
||||
const dn3 = w.cards[0], tn = w.cards[1];
|
||||
assert.equal(dn3.label, 'Devnet 3'); assert.equal(dn3.line, 'igneum-devnet-3, chain id 4463'); assert.equal(dn3.sub, 'staging: the chain may reset, coins have no value'); assert.equal(dn3.open, true);
|
||||
assert.equal(tn.label, 'igneum-testnet-1'); assert.equal(tn.line, 'chain id 4462 · not yet open'); assert.equal(tn.sub, 'the public test chain: coins have no value, resets are announced'); assert.equal(tn.open, false);
|
||||
assert.equal(V.networkWords({ ...fresh, update: { default_network: '', testnet_open: false } }).selected, 'devnet-3', 'no manifest read yet: Devnet 3');
|
||||
const open = V.networkWords({ ...fresh, update: { default_network: 'testnet-1', testnet_open: true } });
|
||||
assert.equal(open.selected, 'testnet-1'); assert.equal(open.cards[1].line, 'chain id 4462'); assert.equal(open.cards[1].open, true);
|
||||
// an existing install keeps what it runs; the card it runs is marked current
|
||||
const old = V.networkWords({ setup_done: true, network_name: 'igneum-devnet-3', network_pending: '', settings: { network: '' }, update: { default_network: 'testnet-1', testnet_open: true } });
|
||||
assert.equal(old.selected, 'devnet-3'); assert.equal(old.current, 'devnet-3'); assert.equal(old.cards[0].current, true);
|
||||
assert.equal(old.line, 'This machine runs igneum-devnet-3.');
|
||||
// the pending state after a confirmed switch
|
||||
const pend = V.networkWords({ setup_done: true, network_name: 'igneum-devnet-3', network_pending: 'testnet-1', settings: { network: 'testnet-1' }, update: { default_network: 'testnet-1', testnet_open: true } });
|
||||
assert.equal(pend.line, 'Switching to igneum-testnet-1 at the next start: quit Igneum Miner and open it again.');
|
||||
for (const s of [w, old, pend]) for (const c of s.cards) assert.equal(/—|–/.test(c.label + c.line + c.sub), false);
|
||||
});
|
||||
|
||||
test('the switch is refused without the confirm, and the confirm names what resets; the testnet choice is refused while not open', () => {
|
||||
assert.equal(V.networkAsk('testnet-1', 'igneum-devnet-3'), 'Switch this machine to igneum-testnet-1? The node’s data and the mining state on igneum-devnet-3 reset at the next start. Your key and your address stay.');
|
||||
assert.equal(V.networkAsk('devnet-3', 'igneum-testnet-1'), 'Switch this machine to igneum-devnet-3? The node’s data and the mining state on igneum-testnet-1 reset at the next start. Your key and your address stay.');
|
||||
// the Rust rule the server applies (config::network_switch): mirrored here by its source text
|
||||
const cfg = rustSrc('config.rs');
|
||||
assert.match(cfg, /pub fn network_switch\(want: &str, running: &str, testnet_open: bool, confirmed: bool\) -> Result<\(\), String>/);
|
||||
assert.match(cfg, /fn network_switch_rules\(\)/, 'the Rust test exists');
|
||||
assert.match(rustSrc('manifest.rs'), /default_network names the testnet before it is open/, 'a manifest with the testnet default before the open is refused');
|
||||
assert.match(rustSrc('manifest.rs'), /fn default_network_rules\(\)/, 'the manifest test exists');
|
||||
assert.match(rustSrc('server.rs'), /"\/api\/network" =>/);
|
||||
// the markup: the step sits before the address step, four steps now; the Settings card exists
|
||||
const idx = pageHtml;
|
||||
assert.ok(idx.indexOf('id="screen-network"') > idx.indexOf('id="screen-region"') && idx.indexOf('id="screen-network"') < idx.indexOf('id="screen-address"'), 'the network step comes after the region step and before the address step');
|
||||
assert.equal((idx.match(/step [1-4] of 4/g) || []).length, 4, 'four steps');
|
||||
assert.ok(idx.includes('id="s-network-cards"') && idx.includes('id="ask-network"'), 'the Settings card and its confirm');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -109,7 +109,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
}
|
||||
|
||||
func buildWindow() {
|
||||
let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize
|
||||
// window-22 (7 October 2026, the one rule with app/windows/opening_size.h): the PRIMARY screen's work area, 80 percent
|
||||
// wide capped at 1440 up to a 1920-wide display and 1920 beyond, height from the width at 16:10 bounded by the work
|
||||
// area less 40, never wider than 1.6 times the height, never under 900 by 600, never over the work area
|
||||
let work = (NSScreen.screens.first ?? NSScreen.main)?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
||||
func openingSize(_ workW: CGFloat, _ workH: CGFloat) -> NSSize {
|
||||
let minW = min(workW, 900), minH = min(workH, 600), capW: CGFloat = workW <= 1920 ? 1440 : 1920
|
||||
var w = min(floor(workW * 0.8), capW); var h = min(floor(w / 1.6), workH - 40)
|
||||
w = max(w, minW); h = max(h, minH); w = min(w, floor(h * 1.6)); w = min(w, workW); h = min(h, workH)
|
||||
return NSSize(width: w, height: h)
|
||||
}
|
||||
func fits(_ f: NSRect) -> Bool { f.width >= 900 && f.height >= 600 && f.width <= work.width && f.height <= work.height && f.width <= f.height * 1.632 }
|
||||
let opening = openingSize(work.width, work.height)
|
||||
let size = snapshotPath == nil ? opening : snapshotSize
|
||||
window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
|
||||
window.title = "Igneum Miner"
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
|
@ -117,7 +129,11 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
window.isMovableByWindowBackground = true
|
||||
window.backgroundColor = obsidian
|
||||
window.minSize = NSSize(width: 900, height: 600)
|
||||
window.center()
|
||||
if snapshotPath == nil {
|
||||
// the remembered frame is kept only when it fits the primary work area and the aspect cap, else discarded
|
||||
if !window.setFrameUsingName("IgneumMinerMain") || !fits(window.frame) { window.setContentSize(opening); window.center() }
|
||||
window.setFrameAutosaveName("IgneumMinerMain")
|
||||
} else { window.center() }
|
||||
window.delegate = self
|
||||
window.isReleasedWhenClosed = false
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
|
|
@ -209,6 +225,9 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
}
|
||||
} else if line.hasPrefix("STATE ") {
|
||||
applyState(String(line.dropFirst(6)))
|
||||
} else if line.hasPrefix("SHOT ") {
|
||||
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
|
||||
shotTo(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces))
|
||||
} else if line.hasPrefix("FATAL ") {
|
||||
fail(String(line.dropFirst(6)))
|
||||
} else if line == "EXIT" {
|
||||
|
|
@ -322,6 +341,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler()
|
||||
}
|
||||
|
||||
// ---- a screenshot on request (the SHOT line), the app keeps running ----
|
||||
func shotTo(_ path: String) {
|
||||
let conf = WKSnapshotConfiguration()
|
||||
conf.rect = web.bounds
|
||||
web.takeSnapshot(with: conf) { image, error in
|
||||
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("shot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
|
||||
return
|
||||
}
|
||||
do { try png.write(to: URL(fileURLWithPath: path)) } catch { FileHandle.standardError.write("shot: could not write \(path): \(error)\n".data(using: .utf8)!) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- snapshot ----
|
||||
func snapshot(to path: String) {
|
||||
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ echo [build] rc
|
|||
rc.exe /nologo /i "%ART%" /fo build\host.res 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\\" host.cpp build\host.res ^
|
||||
/link /SUBSYSTEM:WINDOWS /OUT:"dist\Igneum Miner.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^
|
||||
/link /SUBSYSTEM:WINDOWS /MANIFEST:NO /OUT:"dist\Igneum Miner.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^
|
||||
user32.lib shell32.lib ole32.lib advapi32.lib gdi32.lib version.lib shlwapi.lib || (pause & exit /b 1)
|
||||
echo [build] done: dist\Igneum Miner.exe
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
#define _UNICODE
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include "opening_size.h"
|
||||
#include <shellapi.h>
|
||||
#include <shlwapi.h>
|
||||
#include <dbt.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
|
|
@ -33,17 +35,35 @@ using namespace Microsoft::WRL;
|
|||
#define ID_TRAY_PAUSE 1002
|
||||
#define ID_TRAY_QUIT 1003
|
||||
#define ID_QUIT_TIMER 7
|
||||
// MF-11 (7 October 2026): an engine that stops without a quit is started again (10 s, then 60 s after three in ten
|
||||
// minutes); a second "Igneum Miner.exe" that finds this window asks it to do so now (WM_ENGINE_RESTART), instead of
|
||||
// showing a window that says "the engine stopped" with nothing mining behind it.
|
||||
#define ID_RESTART_TIMER 8
|
||||
#define WM_ENGINE_RESTART (WM_APP + 3)
|
||||
#define RESTART_SOON_MS 10000
|
||||
#define RESTART_SLOW_MS 60000
|
||||
#define RESTART_WINDOW_MS 600000
|
||||
#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;
|
||||
// a PNG of the web view at `path` (the SHOT line); the stream is released by the completion handler
|
||||
static void capturePreview(const std::wstring& path) {
|
||||
if (!g_webview) return;
|
||||
ComPtr<IStream> stream;
|
||||
if (FAILED(SHCreateStreamOnFileEx(path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, FILE_ATTRIBUTE_NORMAL, TRUE, nullptr, &stream))) return;
|
||||
g_webview->CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, stream.Get(), Callback<ICoreWebView2CapturePreviewCompletedHandler>([stream](HRESULT) -> HRESULT { return S_OK; }).Get());
|
||||
}
|
||||
static std::wstring g_url, g_status = L"starting the engine";
|
||||
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 Miner";
|
||||
static ULONGLONG g_quitStarted = 0;
|
||||
static bool g_exitForUpdate = false; // the engine's last line was "EXIT update": an installer or the OTA stops it; this window goes too, no restart
|
||||
static int g_restarts = 0; // engine restarts inside the current window
|
||||
static ULONGLONG g_restartWindowStart = 0; // when that window opened
|
||||
|
||||
static std::wstring widen(const std::string& s) {
|
||||
if (s.empty()) return L"";
|
||||
|
|
@ -253,6 +273,75 @@ static bool startEngine() {
|
|||
return true;
|
||||
}
|
||||
|
||||
// The engine's handles, closed before another engine is started (the reader thread has already left: it posts the
|
||||
// "gone" line only after the pipe closed).
|
||||
static void closeEngine() {
|
||||
if (g_engineIn) { CloseHandle(g_engineIn); g_engineIn = nullptr; }
|
||||
if (g_engineOut) { CloseHandle(g_engineOut); g_engineOut = nullptr; }
|
||||
if (g_engine) { CloseHandle(g_engine); g_engine = nullptr; }
|
||||
}
|
||||
|
||||
// Is an installer running? Its marker (%LOCALAPPDATA%\igneum\app\install-running.flag, written by Igneum-Miner.iss's
|
||||
// PrepareToInstall and removed at its end) holds this window's restart ladder: the old engine must never start again
|
||||
// under an installer (PC 2, 7 October 2026, 20:54 BST). A marker older than 15 minutes is an installer that died.
|
||||
static bool installerRunning() {
|
||||
wchar_t* local = nullptr;
|
||||
size_t len = 0;
|
||||
if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") != 0 || !local) return false;
|
||||
std::wstring p = std::wstring(local) + L"\\igneum\\app\\install-running.flag";
|
||||
free(local);
|
||||
WIN32_FILE_ATTRIBUTE_DATA fad;
|
||||
if (!GetFileAttributesExW(p.c_str(), GetFileExInfoStandard, &fad)) return false;
|
||||
FILETIME now;
|
||||
GetSystemTimeAsFileTime(&now);
|
||||
ULARGE_INTEGER a, b;
|
||||
a.LowPart = fad.ftLastWriteTime.dwLowDateTime; a.HighPart = fad.ftLastWriteTime.dwHighDateTime;
|
||||
b.LowPart = now.dwLowDateTime; b.HighPart = now.dwHighDateTime;
|
||||
ULONGLONG ageS = b.QuadPart > a.QuadPart ? (b.QuadPart - a.QuadPart) / 10000000ULL : 0;
|
||||
return ageS <= 15 * 60;
|
||||
}
|
||||
|
||||
// Starts the engine again after it stopped on its own. Three restarts inside ten minutes come 10 s apart; from the
|
||||
// fourth they come a minute apart, for ever: a miner that sits stopped is a miner lost (plug, tune, play).
|
||||
static void scheduleRestart() {
|
||||
ULONGLONG now = GetTickCount64();
|
||||
if (g_restartWindowStart == 0 || now - g_restartWindowStart > RESTART_WINDOW_MS) { g_restartWindowStart = now; g_restarts = 0; }
|
||||
g_restarts++;
|
||||
UINT delay = g_restarts <= 3 ? RESTART_SOON_MS : RESTART_SLOW_MS;
|
||||
wchar_t buf[160];
|
||||
swprintf_s(buf, L"The engine stopped. Starting it again in %u s (restart %d).", delay / 1000, g_restarts);
|
||||
g_status = buf;
|
||||
setTray(L"Igneum Miner: starting the engine again");
|
||||
repaintStatus();
|
||||
SetTimer(g_hwnd, ID_RESTART_TIMER, delay, nullptr);
|
||||
}
|
||||
|
||||
static void restartEngineNow() {
|
||||
KillTimer(g_hwnd, ID_RESTART_TIMER);
|
||||
if (g_quitting || !g_exited) return;
|
||||
if (installerRunning()) {
|
||||
// held: an installer is replacing the files; this window ends so the installer can replace it too, and the
|
||||
// installer's own [Run] step (or the update helper) starts the new app
|
||||
g_status = L"An update is installing; the app opens again when it is done.";
|
||||
repaintStatus();
|
||||
DestroyWindow(g_hwnd);
|
||||
return;
|
||||
}
|
||||
closeEngine();
|
||||
g_exited = false;
|
||||
g_url.clear();
|
||||
if (startEngine()) {
|
||||
g_status = L"";
|
||||
setTray(L"Igneum Miner: starting");
|
||||
repaintStatus();
|
||||
} else {
|
||||
g_exited = true;
|
||||
g_status = L"igneum-app.exe is missing next to this program. Run the installer again.";
|
||||
repaintStatus();
|
||||
scheduleRestart();
|
||||
}
|
||||
}
|
||||
|
||||
static void showTrayMenu() {
|
||||
HMENU m = CreatePopupMenu();
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Miner");
|
||||
|
|
@ -282,11 +371,73 @@ static void beginQuit() {
|
|||
}
|
||||
}
|
||||
|
||||
// scaling-21 (7 October 2026, the project lead: "the miner needs some scaling so more is visible in the initial window"): the window
|
||||
// opens at about 80 percent of the work area, capped at 1440 by 900 on a display up to 1920 wide and scaled up with the
|
||||
// display beyond that; once the user resizes, the size is remembered per machine under HKCU\Software\Igneum\Miner
|
||||
// (WindowW, WindowH) and used on the next start while it still fits the work area. The Mac window does the same
|
||||
// through its frame autosave name (app/mac/IgneumMiner.swift).
|
||||
static const wchar_t* kSizeKey = L"Software\\Igneum\\Miner";
|
||||
// dpi-23 (7 October 2026): the host is per-monitor DPI aware (host.manifest, and the runtime call below for a Windows that
|
||||
// ignores the manifest), so every rectangle it reads is PHYSICAL pixels; the window's monitor DPI (96 = 100 percent) turns
|
||||
// them into the logical pixels the opening-size rule and the remembered frame use. GetDpiForMonitor is looked up at run
|
||||
// time (Shcore, Windows 8.1 and later) so the build links nothing new; the fallback is the desktop's LOGPIXELSX.
|
||||
typedef HRESULT (WINAPI *GetDpiForMonitorFn)(HMONITOR, int, UINT*, UINT*);
|
||||
static int monitorDpi(HMONITOR mon) {
|
||||
HMODULE sh = LoadLibraryW(L"Shcore.dll");
|
||||
if (sh) { GetDpiForMonitorFn f = (GetDpiForMonitorFn)GetProcAddress(sh, "GetDpiForMonitor"); UINT x = 96, y = 96; if (f && mon && SUCCEEDED(f(mon, 0 /* MDT_EFFECTIVE_DPI */, &x, &y)) && x > 0) { FreeLibrary(sh); return (int)x; } FreeLibrary(sh); }
|
||||
HDC dc = GetDC(nullptr); int dpi = dc ? GetDeviceCaps(dc, LOGPIXELSX) : 96; if (dc) ReleaseDC(nullptr, dc); return dpi > 0 ? dpi : 96;
|
||||
}
|
||||
static void enableDpiAwareness() {
|
||||
// the manifest asks for PerMonitorV2; this is the runtime path for a Windows build that ignores it (older than 1703)
|
||||
HMODULE u = GetModuleHandleW(L"user32.dll"); if (!u) return;
|
||||
typedef BOOL (WINAPI *SetCtxFn)(HANDLE);
|
||||
SetCtxFn setCtx = (SetCtxFn)GetProcAddress(u, "SetProcessDpiAwarenessContext");
|
||||
if (setCtx && setCtx((HANDLE)-4 /* DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 */)) return;
|
||||
typedef BOOL (WINAPI *SetAwareFn)(void);
|
||||
SetAwareFn setAware = (SetAwareFn)GetProcAddress(u, "SetProcessDPIAware"); if (setAware) setAware();
|
||||
}
|
||||
static void saveWindowSize(HWND hwnd) {
|
||||
if (IsIconic(hwnd) || IsZoomed(hwnd)) return;
|
||||
RECT r; if (!GetWindowRect(hwnd, &r)) return;
|
||||
int dpi = monitorDpi(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST));
|
||||
DWORD w = (DWORD)igneum_scale_to_logical(r.right - r.left, dpi), h = (DWORD)igneum_scale_to_logical(r.bottom - r.top, dpi), units = 1;
|
||||
if (w < 900 || h < 600 || w > (DWORD)(h * 1.6)) return;
|
||||
HKEY k; if (RegCreateKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &k, nullptr) != ERROR_SUCCESS) return;
|
||||
RegSetValueExW(k, L"WindowW", 0, REG_DWORD, (const BYTE*)&w, sizeof(w));
|
||||
RegSetValueExW(k, L"WindowH", 0, REG_DWORD, (const BYTE*)&h, sizeof(h));
|
||||
RegSetValueExW(k, L"WindowUnits", 0, REG_DWORD, (const BYTE*)&units, sizeof(units)); // 1 = logical pixels, written by a DPI-aware host
|
||||
RegCloseKey(k);
|
||||
}
|
||||
static void openingSize(int& w, int& h, int& sx, int& sy) {
|
||||
// the PRIMARY monitor's work area, never the virtual desktop (window-22: a two-monitor span opened the app super wide)
|
||||
RECT work = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) };
|
||||
POINT origin = { 0, 0 }; HMONITOR mon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY);
|
||||
MONITORINFO mi = { sizeof(mi) }; if (mon && GetMonitorInfoW(mon, &mi)) work = mi.rcWork; else SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
|
||||
int ww = work.right - work.left, wh = work.bottom - work.top;
|
||||
HKEY k; DWORD sw = 0, sh = 0, n = sizeof(DWORD);
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, KEY_QUERY_VALUE, &k) == ERROR_SUCCESS) {
|
||||
RegQueryValueExW(k, L"WindowW", nullptr, nullptr, (LPBYTE)&sw, &n); n = sizeof(DWORD);
|
||||
RegQueryValueExW(k, L"WindowH", nullptr, nullptr, (LPBYTE)&sh, &n);
|
||||
RegCloseKey(k);
|
||||
}
|
||||
// physical work area and DPI in, physical window out; the rule itself runs in logical pixels (opening_size.h)
|
||||
igneum_opening_size_scaled(ww, wh, monitorDpi(mon), (int)sw, (int)sh, &w, &h, nullptr, nullptr);
|
||||
sx = work.left + (ww - w) / 2; sy = work.top + (wh - h) / 2;
|
||||
}
|
||||
|
||||
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_EXITSIZEMOVE:
|
||||
saveWindowSize(hwnd);
|
||||
return 0;
|
||||
case WM_DPICHANGED: { // the window moved to a monitor of another scale: take the rectangle Windows suggests, WebView2 re-renders at the new DPI
|
||||
const RECT* r = (const RECT*)lp;
|
||||
if (r) SetWindowPos(hwnd, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
return 0;
|
||||
}
|
||||
case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same
|
||||
((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600;
|
||||
return 0;
|
||||
|
|
@ -294,9 +445,12 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
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 Miner again.";
|
||||
setTray(L"Igneum Miner: stopped");
|
||||
repaintStatus();
|
||||
// an installer or the app's own OTA stopped the engine ("EXIT update"): the window ends too, so the installer can
|
||||
// replace this exe, and the old engine is never started again under it (PC 2, 7 October 2026, 20:54 BST)
|
||||
if (g_exitForUpdate) { DestroyWindow(hwnd); return 0; }
|
||||
// not a quit of ours: the engine died, or a local caller (a job) asked it to stop and nothing will start it
|
||||
// again; this window does (MF-11)
|
||||
scheduleRestart();
|
||||
return 0;
|
||||
}
|
||||
std::string* line = (std::string*)lp;
|
||||
|
|
@ -308,18 +462,36 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
applyState(line->substr(6));
|
||||
} else if (line->rfind("ELEVATE ", 0) == 0) {
|
||||
runElevated(widen(line->substr(8)));
|
||||
} else if (line->rfind("SHOT ", 0) == 0) {
|
||||
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
|
||||
capturePreview(widen(line->substr(5)));
|
||||
} else if (line->rfind("FATAL ", 0) == 0) {
|
||||
g_status = widen(line->substr(6));
|
||||
repaintStatus();
|
||||
MessageBoxW(hwnd, g_status.c_str(), L"Igneum Miner", MB_OK | MB_ICONERROR);
|
||||
} else if (*line == "EXIT") {
|
||||
} else if (line->rfind("EXIT", 0) == 0) {
|
||||
g_exited = true;
|
||||
if (g_quitting) DestroyWindow(hwnd);
|
||||
if (line->find("update") != std::string::npos) g_exitForUpdate = true;
|
||||
if (g_quitting || g_exitForUpdate) DestroyWindow(hwnd);
|
||||
}
|
||||
delete line;
|
||||
return 0;
|
||||
}
|
||||
case WM_ENGINE_RESTART:
|
||||
// a second "Igneum Miner.exe" (the update helper, the Start Menu, the relay agent's start-app) found this window:
|
||||
// an engine that is gone starts now, not in its backoff
|
||||
if (g_exited && !g_quitting) restartEngineNow();
|
||||
return 0;
|
||||
case WM_QUERYENDSESSION:
|
||||
// the Restart Manager (an installer's /CLOSEAPPLICATIONS) or a sign-out: this window closes for real, it does not
|
||||
// hide to the tray (WM_CLOSE below is the user's X)
|
||||
beginQuit();
|
||||
return TRUE;
|
||||
case WM_ENDSESSION:
|
||||
if (wp) { if (g_engine && !g_exited) sendEngine("quit"); }
|
||||
return 0;
|
||||
case WM_TIMER:
|
||||
if (wp == ID_RESTART_TIMER) { restartEngineNow(); return 0; }
|
||||
if (wp == ID_QUIT_TIMER) {
|
||||
DWORD code = 0;
|
||||
bool gone = !g_engine || (GetExitCodeProcess(g_engine, &code) && code != STILL_ACTIVE);
|
||||
|
|
@ -421,10 +593,11 @@ static bool handleCliFlags() {
|
|||
|
||||
int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
|
||||
if (handleCliFlags()) return 0;
|
||||
enableDpiAwareness();
|
||||
HANDLE once = CreateMutexW(nullptr, TRUE, L"Local\\IgneumMinerWindow");
|
||||
if (GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||
HWND other = FindWindowW(L"IgneumMinerWindow", nullptr);
|
||||
if (other) { ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
|
||||
if (other) { PostMessageW(other, WM_ENGINE_RESTART, 0, 0); ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
|
||||
return 0;
|
||||
}
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
|
@ -436,8 +609,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
|
|||
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;
|
||||
int w, h, sx, sy; openingSize(w, h, sx, sy);
|
||||
g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Miner", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr);
|
||||
ShowWindow(g_hwnd, SW_SHOW);
|
||||
|
||||
|
|
@ -462,7 +634,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
|
|||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
if (g_engine) { CloseHandle(g_engine); }
|
||||
closeEngine();
|
||||
CloseHandle(once);
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
|
|
|
|||
19
app/windows/host.manifest
Normal file
19
app/windows/host.manifest
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!-- Igneum Miner.exe (the window host): per-monitor DPI awareness v2 (dpi-23, 7 October 2026). Without it Windows
|
||||
stretched the whole window on a scaled panel (PC 2's 5120 by 2160 at 200 percent rendered blurry); with it WebView2
|
||||
renders at the panel's native scale and the host reads physical pixels. Embedded by host.rc as RT_MANIFEST 1;
|
||||
BUILD-APP.bat links with /MANIFEST:NO so the linker's default manifest does not collide. -->
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity type="win32" name="Igneum.Miner" version="1.0.0.0"/>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
1 ICON "igneum.ico"
|
||||
|
||||
// dpi-23: the application manifest (per-monitor DPI awareness v2); the linker runs with /MANIFEST:NO
|
||||
1 24 "host.manifest"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION IGNEUM_HOST_VERSION_RC
|
||||
PRODUCTVERSION IGNEUM_HOST_VERSION_RC
|
||||
|
|
|
|||
42
app/windows/opening_size.h
Normal file
42
app/windows/opening_size.h
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/* The opening window size, one rule for both hosts (window-22, 7 October 2026, the project lead on PC 2: "why did the app open super
|
||||
wide?"). Inputs: the PRIMARY monitor's work area (never the virtual desktop spanning two monitors) and the remembered
|
||||
frame (0 when none). Width first: 80 percent of the work area, at most 1440 on a display up to 1920 wide and at most 1920
|
||||
beyond, whatever the display; height from the width at 16:10, bounded by the work area less 40 px; then the window is
|
||||
never wider than 1.6 times its height, never under 900 by 600 unless the work area itself is smaller, never over the work
|
||||
area. A remembered frame is kept only when it fits the work area, the minimum and the aspect cap, else discarded (a
|
||||
super-wide frame from an earlier host must not come back). Pure C99, no Windows headers: host.cpp includes it,
|
||||
opening_size_test.c compiles it on the gate, IgneumMiner.swift mirrors it line for line. */
|
||||
#ifndef IGNEUM_OPENING_SIZE_H
|
||||
#define IGNEUM_OPENING_SIZE_H
|
||||
static void igneum_opening_size(int workW, int workH, int savedW, int savedH, int* outW, int* outH) {
|
||||
int minW = workW < 900 ? workW : 900, minH = workH < 600 ? workH : 600;
|
||||
int capW = workW <= 1920 ? 1440 : 1920;
|
||||
int w = (int)(workW * 0.8); if (w > capW) w = capW;
|
||||
int h = (int)(w / 1.6); if (h > workH - 40) h = workH - 40;
|
||||
if (w < minW) w = minW;
|
||||
if (h < minH) h = minH;
|
||||
if (w > (int)(h * 1.6)) w = (int)(h * 1.6);
|
||||
if (w > workW) w = workW;
|
||||
if (h > workH) h = workH;
|
||||
/* a remembered frame: the user's own size, kept when it fits; the aspect cap carries a 2 percent tolerance so a hand-sized
|
||||
1329 by 825 (PC 2, 7 October 2026, aspect 1.611) is not thrown away for a few pixels */
|
||||
if (savedW >= 900 && savedH >= 600 && savedW <= workW && savedH <= workH && savedW <= (int)(savedH * 1.632)) { w = savedW; h = savedH; }
|
||||
*outW = w; *outH = h;
|
||||
}
|
||||
/* dpi-23 (7 October 2026): the same rule on a per-monitor-aware host, where the work area and the frame come in PHYSICAL
|
||||
pixels and the monitor's DPI is known (96 = 100 percent). The rule runs in logical pixels (physical x 96 / dpi), so the
|
||||
1440 by 900 and 1920 caps mean CSS pixels as they do on the Mac, and the result goes back to physical for CreateWindow.
|
||||
The remembered frame is logical on both the old host (a DPI-unaware process reads virtualised, logical coordinates) and
|
||||
this one (it saves physical / scale), so no stored value is thrown away for its units; the WindowUnits marker records which
|
||||
host wrote it. PC 2: 5120 by 2112 physical at 192 DPI reads as 2560 by 1056 logical, opens 1625 by 1016 logical, 3250 by
|
||||
2032 physical, sharp. */
|
||||
static int igneum_scale_to_logical(int px, int dpi) { return dpi > 0 ? (int)((long long)px * 96 / dpi) : px; }
|
||||
static int igneum_scale_to_physical(int lg, int dpi) { return dpi > 0 ? (int)((long long)lg * dpi / 96) : lg; }
|
||||
static void igneum_opening_size_scaled(int workWpx, int workHpx, int dpi, int savedWlogical, int savedHlogical, int* outWpx, int* outHpx, int* outWlogical, int* outHlogical) {
|
||||
int lw = 0, lh = 0;
|
||||
igneum_opening_size(igneum_scale_to_logical(workWpx, dpi), igneum_scale_to_logical(workHpx, dpi), savedWlogical, savedHlogical, &lw, &lh);
|
||||
if (outWlogical) *outWlogical = lw;
|
||||
if (outHlogical) *outHlogical = lh;
|
||||
*outWpx = igneum_scale_to_physical(lw, dpi); *outHpx = igneum_scale_to_physical(lh, dpi);
|
||||
}
|
||||
#endif
|
||||
42
app/windows/opening_size_test.c
Normal file
42
app/windows/opening_size_test.c
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/* The opening window size (window-22, 7 October 2026, the project lead on PC 2: "why did the app open super wide?"): the rule in
|
||||
opening_size.h, shared by the Windows host (host.cpp) and mirrored in the Mac window (IgneumMiner.swift). Known-failed
|
||||
first: on 0.3.21's rule a 3440 by 1440 work area opened 2752 by 1152 and a 3840 by 1080 span opened 3072 by 864
|
||||
(the test could not compile before the header existed).
|
||||
cc -std=c99 -Wall -Wextra -o t app/windows/opening_size_test.c && ./t */
|
||||
#include <stdio.h>
|
||||
#include "opening_size.h"
|
||||
static int fails = 0;
|
||||
static void checkS(const char* name, int ww, int wh, int dpi, int sw, int sh, int ew, int eh, int elw, int elh) {
|
||||
int w = 0, h = 0, lw = 0, lh = 0; igneum_opening_size_scaled(ww, wh, dpi, sw, sh, &w, &h, &lw, &lh);
|
||||
if (w != ew || h != eh || lw != elw || lh != elh) { printf("FAIL %s: work %dx%d @%d dpi saved %dx%d -> %dx%d px (%dx%d logical), wanted %dx%d px (%dx%d)\n", name, ww, wh, dpi, sw, sh, w, h, lw, lh, ew, eh, elw, elh); fails++; }
|
||||
else printf("ok %s: %dx%d px, %dx%d logical\n", name, w, h, lw, lh);
|
||||
}
|
||||
static void check(const char* name, int ww, int wh, int sw, int sh, int ew, int eh) {
|
||||
int w = 0, h = 0; igneum_opening_size(ww, wh, sw, sh, &w, &h);
|
||||
if (w != ew || h != eh) { printf("FAIL %s: work %dx%d saved %dx%d -> %dx%d, wanted %dx%d\n", name, ww, wh, sw, sh, w, h, ew, eh); fails++; }
|
||||
else printf("ok %s: %dx%d\n", name, w, h);
|
||||
}
|
||||
int main(void) {
|
||||
check("1080p desk: 1440 by 900", 1920, 1040, 0, 0, 1440, 900);
|
||||
check("1440p monitor: width capped at 1920, 16:10", 2560, 1400, 0, 0, 1920, 1200);
|
||||
check("4K: width capped at 1920 regardless of display", 3840, 2120, 0, 0, 1920, 1200);
|
||||
check("ultrawide 3440 by 1440: never wider than 1.6 times the height", 3440, 1400, 0, 0, 1920, 1200);
|
||||
check("two monitors spanned 3840 by 1080, if ever read as one area: the aspect cap holds", 3840, 1040, 0, 0, 1600, 1000);
|
||||
check("a remembered frame that fits is kept", 1920, 1040, 1400, 900, 1400, 900);
|
||||
check("PC 2, 7 October 2026: a 5120 by 2160 panel at 200 percent reads as 2560 by 1032; 0.3.21 opened 1920 by 826", 2560, 1032, 0, 0, 1587, 992);
|
||||
check("PC 2: the hand-sized 1329 by 825 (aspect 1.611) is kept under the 2 percent tolerance", 2560, 1032, 1329, 825, 1329, 825);
|
||||
check("a remembered frame at aspect 1.7 is discarded", 2560, 1032, 1700, 1000, 1587, 992);
|
||||
check("a remembered super-wide frame from the take-4 host is discarded", 1920, 1040, 2752, 1152, 1440, 900);
|
||||
check("a remembered frame wider than 1.6 times its height is discarded", 1920, 1040, 1800, 700, 1440, 900);
|
||||
check("a remembered frame under the minimum is discarded", 1920, 1040, 800, 500, 1440, 900);
|
||||
check("a small laptop: 80 percent wide, 16:10", 1280, 760, 0, 0, 1024, 640);
|
||||
check("a tiny work area: the work area itself", 800, 560, 0, 0, 800, 560);
|
||||
/* dpi-23: the per-monitor-aware host reads physical pixels and the monitor's DPI */
|
||||
checkS("1080p at 100 percent: unchanged", 1920, 1040, 96, 0, 0, 1440, 900, 1440, 900);
|
||||
checkS("PC 2: 5120 by 2112 physical at 200 percent (2560 by 1056 logical)", 5120, 2112, 192, 0, 0, 3250, 2032, 1625, 1016);
|
||||
checkS("4K at 150 percent: 1920 by 1200 logical, 2880 by 1800 physical", 3840, 2120, 144, 0, 0, 2880, 1800, 1920, 1200);
|
||||
checkS("a 1440p panel at 125 percent (2048 by 1120 logical)", 2560, 1400, 120, 0, 0, 2045, 1278, 1636, 1023);
|
||||
checkS("PC 2's remembered 1329 by 825 (logical, from the unaware host) is kept and placed at 200 percent", 5120, 2112, 192, 1329, 825, 2658, 1650, 1329, 825);
|
||||
checkS("a 250 percent laptop panel (1536 by 832 logical): 80 percent wide at 16:10", 3840, 2080, 240, 0, 0, 3067, 1917, 1227, 767);
|
||||
printf(fails ? "%d FAILED\n" : "all ok\n", fails); return fails ? 1 : 0;
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@
|
|||
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
|
||||
#ifndef IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_STR "0.3.14"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,14,0
|
||||
#define IGNEUM_HOST_VERSION_STR "0.3.23"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,23,0
|
||||
// The WebView2 runtime the host's loader needs at least (the SDK 1.0.2903.40 loader's minimum, from its release note at the
|
||||
// cut); the installer's rights step (src/rights.rs) installs the evergreen runtime when the PC's is absent or below it, and a
|
||||
// raised minimum is a new right the next update asks once for.
|
||||
#define IGNEUM_WEBVIEW2_MIN "109.0.1518.78"
|
||||
#endif
|
||||
|
|
|
|||
14
brand/marks/regen.mjs
Normal file
14
brand/marks/regen.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
// Regenerates brand/marks/vendor-marks.mjs from app/igneum-app/ui/app.js (View.MARKS, View.VENDORS): run after any
|
||||
// change to the marks in the app, then commit both. Keeps the header and the OS marks; only the data blocks move.
|
||||
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, 'vendor-marks.mjs');
|
||||
let s = readFileSync(p, 'utf8');
|
||||
s = s.replace(/export const VENDORS = [\s\S]*?;\n/, 'export const VENDORS = ' + JSON.stringify(V.VENDORS, null, 2) + ';\n');
|
||||
s = s.replace(/export const WELL_ALPHA = [^\n]*\n/, 'export const WELL_ALPHA = { dark: ' + V.WELL_ALPHA_DARK + ', light: ' + V.WELL_ALPHA_LIGHT + ' };\n');
|
||||
s = s.replace(/export const MARKS = [\s\S]*?\n\};\n/, 'export const MARKS = ' + JSON.stringify(V.MARKS, null, 2) + ';\n');
|
||||
writeFileSync(p, s); console.log('brand/marks/vendor-marks.mjs regenerated');
|
||||
65
brand/marks/vendor-marks.mjs
Normal file
65
brand/marks/vendor-marks.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Igneum vendor and OS marks (gpu-logos, 7 October 2026): the strings the miner app ships in app/igneum-app/ui/app.js
|
||||
// (View.MARKS and View.VENDORS), exported verbatim for the site and anything else that names the hardware.
|
||||
// view.test.mjs fails when this file and app.js drift apart, so edit app.js first and regenerate this file with
|
||||
// `node brand/marks/regen.mjs` (or copy the strings by hand; the test says which one moved).
|
||||
//
|
||||
// Each glyph: a hand-drawn simplified monochrome mark of the vendor's public geometry (never a copied logo file,
|
||||
// never a raster), 24 x 24 viewBox at 22 px, under 460 bytes, fill or stroke through currentColor so the element's
|
||||
// colour tints it. Nominative use that names the hardware; the ember accent is for state and never tints a brand.
|
||||
//
|
||||
// The treatment in the app (app.css, the block at the end): a 44 x 44 well, radius 12, background the vendor colour
|
||||
// at .14 alpha (dark) or .10 (light), a 1 px ring in the vendor colour at .45 alpha, the glyph in the full colour;
|
||||
// hover and focus-within add a 3 px halo of the well colour; nothing animates. The light hex of every vendor reads at
|
||||
// 3:1 or better on its well over white (nvidia 3.87, amd 5.01, intel 4.29, apple 7.52, gpu 4.65).
|
||||
//
|
||||
// Class names in the app: .badge.<vendor> (nvidia | amd | intel | apple | gpu), .badge.mini for a 26 px inline mark,
|
||||
// .gen for the series line under the name. Tokens: --mark-<vendor>, --mark-<vendor>-well, --mark-<vendor>-ring.
|
||||
|
||||
export const VENDORS = {
|
||||
"nvidia": {
|
||||
"label": "NVIDIA",
|
||||
"dark": "#8BE37A",
|
||||
"light": "#2F8A22"
|
||||
},
|
||||
"amd": {
|
||||
"label": "AMD Radeon",
|
||||
"dark": "#FF5A5A",
|
||||
"light": "#C41E2A"
|
||||
},
|
||||
"intel": {
|
||||
"label": "Intel",
|
||||
"dark": "#7CC4FF",
|
||||
"light": "#1C6FD6"
|
||||
},
|
||||
"apple": {
|
||||
"label": "Apple",
|
||||
"dark": "#E6E3DD",
|
||||
"light": "#4A4A50"
|
||||
},
|
||||
"gpu": {
|
||||
"label": "GPU",
|
||||
"dark": "#9A9A9E",
|
||||
"light": "#6B6B70"
|
||||
}
|
||||
};
|
||||
export const WELL_ALPHA = { dark: 0.14, light: 0.1 };
|
||||
export const MARKS = {
|
||||
"nvidia": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.6 12C5 7.9 8.3 5.8 12 5.8s7 2.1 9.4 6.2c-2.4 4.1-5.7 6.2-9.4 6.2S5 16.1 2.6 12z\"/><path d=\"M15.6 12A3.6 3.6 0 1 0 12 15.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/></svg>",
|
||||
"amd": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M8 3h13v13l-4-4V7h-5z\"/><path d=\"M3 8l4 4v5h5l4 4H3z\"/></svg>",
|
||||
"intel": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><path d=\"M7.4 7.6C4.9 8.8 3.4 10.5 3.4 12.3c0 3.4 4.3 5.9 9.8 5.9 4.5 0 7.6-1.6 7.6-3.9 0-1.4-1.3-2.6-3.5-3.3\"/><path d=\"M11.2 9.6v6.2\"/><circle cx=\"11.2\" cy=\"6.6\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\"/></svg>",
|
||||
"apple": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z\"/></svg>",
|
||||
"gpu": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><rect x=\"5.5\" y=\"5.5\" width=\"13\" height=\"13\" rx=\"2.2\"/><rect x=\"9.5\" y=\"9.5\" width=\"5\" height=\"5\" rx=\"1\"/><path d=\"M9 2.5v3M12 2.5v3M15 2.5v3M9 18.5v3M12 18.5v3M15 18.5v3M2.5 9h3M2.5 12h3M2.5 15h3M18.5 9h3M18.5 12h3M18.5 15h3\"/></svg>"
|
||||
};
|
||||
// OS marks in the same treatment: Apple is the vendor glyph; Windows is the four slanted panes
|
||||
export const OS_MARKS = {
|
||||
macos: MARKS.apple,
|
||||
windows: "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M3 5.6l7.3-1v7.1H3zM11.4 4.4L21 3v8.7h-9.6zM3 12.3h7.3v7.1L3 18.4zM11.4 12.3H21V21l-9.6-1.4z\"/></svg>"
|
||||
};
|
||||
export const OS_COLOURS = { macos: VENDORS.apple, windows: { label: 'Windows', dark: '#7CC4FF', light: '#1C6FD6' } };
|
||||
// the CSS tokens for both themes, as the app declares them
|
||||
export function tokensCss() {
|
||||
const line = (theme) => Object.entries(VENDORS).map(([v, c]) => { const h = c[theme], r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16), a = String(WELL_ALPHA[theme]).replace(/^0/, ''); return `--mark-${v}:${h};--mark-${v}-well:rgba(${r},${g},${b},${a});--mark-${v}-ring:rgba(${r},${g},${b},.45)`; }).join(';');
|
||||
return `:root{${line('dark')}}\n@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){${line('light')}}}\n:root[data-theme="light"]{${line('light')}}`;
|
||||
}
|
||||
// the well: <div class="badge nvidia"><svg…></svg></div>
|
||||
export function markHtml(vendor, size) { const v = MARKS[vendor] ? vendor : 'gpu'; return '<div class="badge ' + (size ? size + ' ' : '') + v + '" data-mark="' + v + '" title="' + VENDORS[v].label + '">' + MARKS[v] + '</div>'; }
|
||||
|
|
@ -2563,6 +2563,35 @@ task now runs ...Programs\Igneum Miner\igneum-app.exe --power-helper"), then the
|
|||
task with no prompt ("-pl 460: set to 460.00 W from 575.00 W", "-pl 160: set to 160.00 W from 100.00 W"). Task
|
||||
Running, Highest, user Admin. Cards: 5090 221 W at 1845 MHz, 4070 75.8 W at 1860 MHz, 9070 XT 202 W, all mining
|
||||
through the installed app. Ember closed 16:53Z.
|
||||
### 6 October 2026, 17:55Z to 17:57Z, the first segment above the restart cut from a snapshot-restored node on the new export (Mac)
|
||||
|
||||
A copy of node 1's data dir started on the shipper's 0.3.14 Mac binary (target-0314, release-0.3.14-node 4c6b129d) with
|
||||
`--igneum-exec-snapshot=node1-copy-snapshot.bin,0xac101f13...` (the hands' recovery file, tip 130,272) and node 1 as its
|
||||
peer: IBD of 8,049 blocks in 29 s, the executor resumed from the file and reached the tip 140,023 with no restart replay.
|
||||
`igneum_exportSegments [139951, 139959]` answered in under 0.1 s with 9 segments, `preState` 79 accounts, `execRestart`
|
||||
27,276. The branch exporter (exec-app-0314 bfebff1) cut all 8 blocks of segment 139,952..139,959 in under 0.1 s each:
|
||||
"replayed 8 segments from the account dump; every state root equals the node's". The first block's fixture carries
|
||||
pre-state root 0x64304441... and post-state root 0x60d60bba..., equal to node 1's eth_getBlockByNumber roots at 139,951
|
||||
and 139,952 (an independent node). Consequence: a prover on a snapshot-restored node (every hand, nine fleet boxes) cuts a
|
||||
provable fixture again with the 0.3.14 app and exporter; what is left for a paid record is the proof itself (the PCs or
|
||||
the fleet, not this Mac).
|
||||
|
||||
### 6 October 2026, 18:11Z to 18:13Z, the PC shape over p2p (Mac, `tools/lock/with-lock.sh run`)
|
||||
|
||||
Fork 1255c0f9. `node tools/exec-sync/net.mjs`: 18/18 in 117.5 s. Case 7: a node whose data dir holds the tip-0 pair the 0.3.13
|
||||
genesis replay left behind, with `--igneum-exec-snapshot=peer`, sets the pair aside (.bin.tip0), asks A over p2p and loads
|
||||
A's state with no hand action; its state root at 60 equals A's. `reorg.mjs` 18/18 in 262.1 s on the same binary; the exec
|
||||
suite 20. Consequence: a PC or a box left with the tip-0 pair recovers on its own once a hand serves (the 5-minute
|
||||
no-progress rule fires the ask even when the error text is new); until 0.3.14.1 the recovery file by hand is the way.
|
||||
|
||||
## 6 October 2026, 18:18Z: the shipped 0.3.14 app asked to tune itself on PC 1 (ember-installed-pc1-1): FAILED, no tune ran
|
||||
|
||||
Gate ok (0.3.14, Power control on, the task on the installed exe). Every card's "after" was its 17:55Z failure from
|
||||
the app's own first tune: 5090 and 4070 "did not take within 30 s (card reports 460 / 160 W, acknowledged true)",
|
||||
9070 XT "the card refused the setting"; the three "Tune now" requests sat queued behind the hour's back-off. Five
|
||||
causes (A to E) and their fixes in the plan's 0.3.14 window section; the window repeats on 0.3.15. The cards kept
|
||||
mining at the driver's held locks: 5090 222.9 W at 1845 MHz, 4070 75.9 W at 1860 MHz, 9070 XT 201 W. Lever-2 TUNING
|
||||
records during the window: 5090 135.6 MH/s at 236 W (0.574 MH/W), 4070 31.1 MH/s at 79 W (0.393).
|
||||
|
||||
## Prover tiers on real cards: the rented fleet, 6 October 2026 (branch gpu-fleet)
|
||||
|
||||
|
|
@ -2616,6 +2645,49 @@ USD 20 an hour on community pods, against a devnet of 1.16 GH/s.
|
|||
Consequence: the devnet's hash is rentable for the price of a dinner, so nothing on it is a security result; the counter-ASIC and
|
||||
finality work is tested there for correctness, not for cost. The cost argument only starts at the TH/s scale, where the rental
|
||||
market's supply (not its price) is the limit, and that number belongs in the litepaper with this caveat.
|
||||
### 6 October 2026, 20:38Z to 21:03Z, proof verification on the consensus path (fork exec-sync-0313 da2d17ec)
|
||||
|
||||
The in-process SP1 verifier (sp1-sdk LightProver, the embedded keys) on a compressed shard proof of
|
||||
block-58927-empty-reward (1,272,897 B), one core, including the LightProver setup each call:
|
||||
|
||||
| Machine | Profile | Verify | Command |
|
||||
|---|---|---|---|
|
||||
| M5 Max (this Mac) | release | 0.204 s, 0.232 s (two runs) | `cargo test --release -p igneum-exec nativeverify -- --nocapture` with the fixture |
|
||||
| M5 Max | debug | 1.226 s | the same without `--release` |
|
||||
| igneum-build-1 (Linux, the build box) | release | 0.400 s | `tools/build-remote.sh --no-fetch -- test --release -p igneum-exec nativeverify -- --nocapture` |
|
||||
| EPYC 7K62, 2019 Zen 2 (rz-4090, earlier today) | release, through the host | 0.53 s | bench/proof-systems rows |
|
||||
|
||||
`node tools/exec-sync/forged.mjs` (fast-time simnet, `proving_consensus_verify_daa` 0 under the embedded ids): 8/8 in
|
||||
23.6 s. The attacker A (`IGNEUM_TEST_SKIP_PROOF_RULE=1`, Trust pool) carried a forged shard record at block 27; B asked
|
||||
A for the proof (22:03:31.270 local), held it 1 ms later, refused the block at 31.522 ("the proof bytes are not a bincode
|
||||
SP1 proof"; the verify itself 0.000 s), never included it and paid nothing; before the forgery B followed A block for
|
||||
block. An unmodified A refused its own carrying block ("block is known to be invalid", 20:45Z run).
|
||||
|
||||
Consequence per tier: the rule costs an honest node nothing on the hot path (a proof verified at relay time is a
|
||||
cache hit); a cold proof costs a 2019 core 0.4 to 0.5 s and a current one 0.2 s, in parallel per record; a forger
|
||||
loses its block and its peer. Until 0.3.17.1's finality-horizon skip, a fresh joiner fetches every carried proof in
|
||||
its IBD window from its syncer (1.27 MB a record), so a syncer must still hold them.
|
||||
|
||||
### 6 October 2026, 22:10Z to 22:28Z, Devnet 2: the zero program-id class and its close (the fleet's reads)
|
||||
|
||||
Devnet 2 (igneum-devnet-2, five nodes on 4c6b129d, 1 block/s, every switch at DAA 0): the nodes ran without a verifier
|
||||
host or IGNEUM_PROOF_PROGRAM_IDS, so their expected segment statements carried zero program ids at hex offset 472 and
|
||||
every segment record from the shipped host was refused while 8 of 8 shards passed (seg 2868: chain proof 1.27 MB in
|
||||
281 s on a 4090). Fix on 4c6b129d: IGNEUM_PROOF_PROGRAM_IDS=0x2b1a81cb...,0x474678f3... on each node process, restart
|
||||
22:10Z to 22:16Z, same genesis. Result: paidSegments 2 on every node at 22:22:28Z and 4 at 22:28:10Z (two provers,
|
||||
8-block segments), 0 PoW rejected, one exec state root on all five at height 3,792 (0xab19a0be...), one version
|
||||
(2.1.0-1279a1d6, digest 4a0b8726). On the 0.3.17 node (fork a344fea3) the override object's proving_shard_program_id
|
||||
and proving_aggregator_id are the statement's ids when set, so the env is not needed. Consequence per tier: a node
|
||||
without a host (a pool hand, a seed, a home node that does not prove) must carry the ids in its object or its env, or
|
||||
it refuses every segment record and pays no aggregator; the 0.3.17 object carries them.
|
||||
## 6 October 2026, 22:16Z: the shipped 0.3.16 app asked to tune itself on PC 1 (ember-installed-pc1-2): A to E held, no climb (cause F)
|
||||
|
||||
Gate ok (0.3.16, Power control on, task on the installed exe, Running at the end), prompts 0, the helper up once per
|
||||
NVIDIA card. RX 9070 XT measured as it runs: 19.2 MH/s at 202 W (0.095 MH/W), no set sent (measure only: the probe
|
||||
gave no tune line; open). RTX 5090 and 4070 climbs stopped at request 1: "the helper did not run sequence 1 within
|
||||
15 s"; cmd.txt held "00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc", two-digit wire numbers below the cap path's unix-based
|
||||
ones, so the helper skipped them as stale (cause F, fixed cbd4d51 for 0.3.17). Draws unchanged: 5090 208 W at
|
||||
1845 MHz, 4070 75.5 W at 1860 MHz, 9070 XT 203 W.
|
||||
|
||||
## Block rate on Devnet 2, 6 October 2026 (branch gpu-fleet): 10 blocks per second against 1 on 42 rented cards
|
||||
|
||||
|
|
|
|||
78
docs/community/discord/ladder.md
Normal file
78
docs/community/discord/ladder.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Discord: the ladder's rung announcements (message shapes, not posted)
|
||||
|
||||
7 October 2026, miner-ui-5 (mission item 6, docs/analysis/mission/mission.md 2.6). The shapes the bot would post for
|
||||
each rung of the miner's ladder, so the words in Discord match the app and the site rung for rung. Nothing here is
|
||||
posted by this lane; the Discord lane wires them into `tools/community/discord-hooks.mjs` behind its guard rails
|
||||
(docs/community/discord-hooks.md: the forbidden-string guard, the limits, idempotency keys, never a mention). Every
|
||||
number is a chain fact from the public API (`/api/live`, `/api/stats`), which is the observer's copy of the node's
|
||||
`getFinalityWeights` and `getFinalityCheckpoints`; a post names nothing the chain does not say.
|
||||
|
||||
## The rules every rung post obeys
|
||||
|
||||
| Rule | How |
|
||||
|---|---|
|
||||
| The miner's object, never the project's | a post carries the key id, the block link, the rank and the day; never the network hashrate, the block count or a milestone of the project |
|
||||
| A chain fact only | the trigger is a field of `getFinalityWeights` (`keys[].blocks`, `keys[].voter`, `keys[].participation`, `voters`) or `getFinalityCheckpoints` (`latestLockedIndex`), read through `/api/live finality.weights` |
|
||||
| Opt-in for anything beyond the key id | the card model and the "(you)" link to the address page appear only when the miner switched "Make my page public" on in the app (settings.profile_public); without it the post is the key id and the block link |
|
||||
| Short key id | the first 8 hex of the vote key hash, the same id the app, `/live` and the explorer show; never a payout address, never a machine name |
|
||||
| One post per key per rung | idempotency key `rung:<rung>:<keyid>`; a rung is posted once for a key, ever (a key that drops and climbs back is not posted again) |
|
||||
| Copy law | no em dashes, no two-beat antithesis, no aphorisms; the numbers in the line, nothing decorative |
|
||||
| Volume | #first-blocks takes rungs 0 and 1 (one line each); #numbers takes the daily ladder summary; rungs 2 to 5 and P are never posted singly (one a minute at scale), they are counted in the summary |
|
||||
|
||||
## Channel: #first-blocks
|
||||
|
||||
### Rung 0, first block (trigger: a key's first blue block in the window; `keys[].blocks` goes 0 to 1 in `finality.weights`, and the block's `miner` is that key in `/api/live blocks[]`)
|
||||
|
||||
```
|
||||
First block: key 6ad0e117
|
||||
Block 1,284,117 · igneum.network/block/9f3a…c21e
|
||||
RTX 4070 (the miner chose to show the card)
|
||||
```
|
||||
|
||||
Without the opt-in the third line is absent. Embed: ember accent, title "First block", one field "Key" = `6ad0e117`, one field "Block" = the explorer link; footer the UK time with UTC in brackets (the hooks script's footer rule).
|
||||
|
||||
### Rung 1, the vote (trigger: `keys[].voter` goes false to true, that is `blocks` reaches `params.dust`)
|
||||
|
||||
```
|
||||
Your key has a vote: 6ad0e117
|
||||
100 blocks in the window (the line is 100) · the key now signs every checkpoint
|
||||
```
|
||||
|
||||
On the devnet the line reads `5 blocks in the window (the line is 5)`: the number is `params.dust`, never a constant.
|
||||
|
||||
## Channel: #numbers, the daily ladder summary (09:00 UK, beside the daily digest)
|
||||
|
||||
```
|
||||
The ladder, yesterday
|
||||
First blocks: 14 keys found their first block
|
||||
Votes: 9 keys crossed the dust line (now 1,213 voters)
|
||||
Signatures: 1,201 keys signed a locked checkpoint (latest lock 184,220)
|
||||
Full window: 406 keys at 30 of 30 days
|
||||
Rank 1 by weight: 6e80f3ef with 2,592 blocks in the window
|
||||
Signing streak, longest: 41 days unbroken (presence 100%)
|
||||
Shards: 388 paid to 57 keys
|
||||
```
|
||||
|
||||
Fields and sources, one per line:
|
||||
|
||||
| Line | Field |
|
||||
|---|---|
|
||||
| First blocks | keys whose `blocks` went from 0 to at least 1 between the two daily reads of `finality.weights.keys[]` |
|
||||
| Votes | keys whose `voter` went false to true; `voters` for the total |
|
||||
| Signatures | keys with `participation` above 0 at the day's last read; `latest_locked_index` for the lock |
|
||||
| Full window | keys whose `blocks` span the whole `params.weightWindow` (owed from the node: `keys[].daysMined`; until then the line is left out, never estimated) |
|
||||
| Rank 1 | `keys[]` sorted by `blocks`, the top row |
|
||||
| Signing streak | owed from the node (`keys[].streakDays`); until then the line is left out |
|
||||
| Shards | `/api/live proving.shards_paid_10m` summed over the day's reads, provers from `live_proofs.prover` |
|
||||
|
||||
A line whose field the node does not expose yet is left out of the post rather than estimated (the "no number a company server has to be trusted for" rule, reinvent.md section 6).
|
||||
|
||||
## Roles (the Discord lane, from the same fields)
|
||||
|
||||
| Role | Granted when | Revoked when |
|
||||
|---|---|---|
|
||||
| Voter | a message signed with the vote key names a key whose `voter` is true in `getFinalityWeights` | `voter` false at a daily read (a key under dust, or stripped: `strippedUntilDaa` above the DAA score) |
|
||||
| Window | the key's blocks span the full `params.weightWindow` (`keys[].daysMined` owed; until then by hand from the ladder summary) | the key misses a day |
|
||||
| Prover | a paid shard record names the key (`igneum_getProofRecords(block).paid[shard]`) | never (a paid record is a chain fact) |
|
||||
|
||||
The signed message is the proof of key ownership; the app does not sign it yet (owed: a "prove my key" button that signs a nonce with the vote key and copies it).
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 362.8 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.3 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.445 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.241 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.106 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 7.004 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 372.7 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.905 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.731 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.723 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 8.714 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 368.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.5 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.089 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.944 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.935 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 4.934 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.2 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.1 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.201 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 5.057 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 5.059 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 5.067 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 366.6 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.512 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 5.350 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 5.342 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 5.342 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.0 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 6.044 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 5.887 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 5.887 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 5.900 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 486.8 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.386 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.244 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.254 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 7.231 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 541.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 525.0 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.834 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.701 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.717 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 8.712 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 540.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 426.2 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.078 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.936 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.928 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 4.931 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 429.9 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.201 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 5.070 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 5.062 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 5.055 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 436.9 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.497 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 5.360 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 5.359 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 5.350 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 539.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 450.4 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 6.066 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 5.881 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 5.884 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 5.829 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
host igneum-build-1 load 26.61 13.24 8.88 freq40 1519940 kHz siblings 40,88 pow 3f7623f208335f5b
|
||||
load after 19.77 12.55 8.74
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.4 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 9.918 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 9.703 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 9.691 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 10.870 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 545.8 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 11.728 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 11.491 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 11.493 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 13.125 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 365.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 545.3 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.146 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 6.915 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 6.897 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 7.421 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.5 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.291 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 7.057 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 7.070 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 7.587 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.1 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.627 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 7.404 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 7.380 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 8.023 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 544.8 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.290 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 8.085 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 8.051 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 8.846 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
10
docs/design/latency-ladder-bench/20261006T220642Z/table.md
Normal file
10
docs/design/latency-ladder-bench/20261006T220642Z/table.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
host igneum-build-1 load 26.61 13.24 8.88 freq40 1519940 kHz siblings 40,88 pow 3f7623f208335f5b
|
||||
load after 19.77 12.55 8.74
|
||||
| Rung | reps | Shadow instrs per hash | Counted ops (approx) | Cold, core alone (ms) | Avg of 50, alone (ms) | Cold, sibling loaded (ms) | Avg of 50, sibling loaded (ms) | Under 10 ms loaded |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 0 | 27 | 55296 | 102,122 | 5.09 | 4.93 | 5.08 | 4.93 | yes |
|
||||
| 1 | 35 | 71680 | 132,105 | 5.20 | 5.07 | 5.20 | 5.05 | yes |
|
||||
| 2 | 53 | 108544 | 199,566 | 5.51 | 5.34 | 5.50 | 5.35 | yes |
|
||||
| 3 | 88 | 180224 | 330,740 | 6.04 | 5.90 | 6.07 | 5.83 | yes |
|
||||
| 4 | 173 | 354304 | 649,307 | 7.45 | 7.00 | 7.39 | 7.23 | yes |
|
||||
| 5 | 267 | 546816 | 1,001,604 | 8.90 | 8.71 | 8.83 | 8.71 | yes |
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.6 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.380 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.232 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.230 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 7.233 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.0 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 367.0 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.881 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.723 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.707 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 8.499 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 460.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.6 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.141 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.954 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.969 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 4.946 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 456.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 437.3 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.571 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 5.327 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 5.203 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 5.521 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 368.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.8 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.569 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 5.352 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 5.366 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 5.351 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 360.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 361.8 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 6.073 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 5.900 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 5.883 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 5.892 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 548.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 551.8 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 12.383 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 11.970 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 12.177 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 12.091 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 548.8 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 556.3 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 14.959 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 14.619 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 14.437 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 14.580 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 551.2 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 561.9 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.769 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 8.503 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 8.469 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 8.674 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 549.4 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 554.8 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.867 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 8.505 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 8.517 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 8.510 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 541.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 549.4 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 9.233 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 8.869 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 8.943 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 8.908 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 556.0 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 10.081 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 9.845 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 9.944 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 9.842 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
host igneum-build-1 load 25.39 17.85 11.63 freq40 1500000 kHz siblings 40,88 pow 3f7623f208335f5b
|
||||
load after 23.60 18.46 12.05
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.9 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.394 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.241 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.269 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 365.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 372.0 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.930 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.716 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.762 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 369.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 370.0 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.129 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.944 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.947 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue