Counter ASIC 3.0 gates (node): the two cut preconditions: the fleet rehearsal plan (counter-asic-3-rehearsal.md, the publish and rehearsal override objects with their digests ac8e60ce... and bc2142b1...), the PROPOSED miner-signalled activation (node doc section 6), the fast-time signal gate class-v4-signal.mjs, the signal window in override-60x.json (120) and the README

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 17:27:50 +00:00
parent c314790dcf
commit e6be730018
7 changed files with 389 additions and 0 deletions

View file

@ -0,0 +1 @@
{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200,"program_class_v3_activation_daa":154800,"proving_v1_activation_daa":154800,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":198000,"exec_restart_number":27276,"exec_restart_hash":"bb45cf0dd2d7cc97ebfa5a2701527c09a8ede5d32de74efead9caa293b15688a","exec_restart_trust_daa":200000,"program_class_v4_activation_daa":219600,"program_class_v4_signal_window_daa":86400}

View file

@ -0,0 +1 @@
{"difficulty_v2_activation_daa":0,"proving_v0_activation_daa":0,"fees_v1_activation_daa":0,"finality_v3_activation_daa":0,"program_class_v3_activation_daa":0,"proving_v1_activation_daa":0,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":0,"exec_restart_number":18446744073709551615,"exec_restart_hash":"","exec_restart_trust_daa":18446744073709551615,"program_class_v4_activation_daa":14400,"program_class_v4_signal_window_daa":3600}

View file

@ -88,3 +88,36 @@ The hash lane found the seven gate packs under `proto-cuda/packs-ca3-v4` carryin
- Wire: `RpcPowEpochInfo` gained one Borsh field (wRPC, versioned by `GetBlockTemplateResponse`) and one optional proto field; a 0.3.13 miner against this node reads the v4 height as never (the field absent) and would key epochs on the v3 switch alone, so every miner moves in the same binary sweep as the node (the rollout order of 2.0).
- The build job has no `features` key for test units (G6 row): the feature was on through unification; adding the key needs the PC's installed app to honour it, a 0.3.14 app item.
- The 48 GiB APFS clone of the 0.3.13 target dir (`vendor/igneum-node-ca3v4/target-ca3v4`, untracked) can be deleted after the cut.
## 6. PROPOSED: miner-signalled class activation (precondition 2 of the cut)
Status: PROPOSED spec text for spec 01 (a new section 1.12.2 beside the epoch rule) and spec 02; implemented behind the override on the fork branch `ca3-v4-node` (`consensus/src/processes/class_signal.rs`, the pure rule in `consensus/core/src/igneum.rs`), gated by the fast-time runs of section 6.4. Not in `docs/spec` until the project lead adopts it. The 2.0 fixed height stays, as the floor.
### 6.1 The rule
1. **The carrier: the block header's `version` field, 2 bytes little-endian.** The low byte is the block version as before (2 on every Igneum network; `block_version_of`). The high byte is the producer's OBJECT VERSION, the highest program class the node that built the template runs: 4 for this binary (`CLASS_SIGNAL_V4`), 0 on every block made before it (`signalled_version(2, 4) = 0x0402`; `class_signal_of`). Why the header and not the coinbase extra data: the tally is read in header validation and by a node that holds headers only (a pruning-proof sync, a headers-first IBD), the header is what GHOSTDAG orders and what the finality rule already reads for its weight (the vote key hash is a header field, `finality.rs`), and the bytes are already there: no new field, no new hash preimage, no proto change for the header. A node before this binary rejects any header whose version is not exactly 2 (`check_header_version`), which is why the signalling binary ships in the same one-sweep rollout as the digest flip it already needs (section 2); from this binary on, only the low byte is checked, so a later object (5, 6) can be signalled to it without another header rule.
2. **The weight: blue blocks, the finality rule's convention.** For epoch `e` the anchor is its seed block `S_e`, the last selected-chain block whose DAA score is below `L e - lead` (the block the epoch seed is already taken from, `HeaderProcessor::epoch_seed`; so an epoch's seed and its class are decided at the same block of the same chain). The window is the `W` DAA below and including `S_e`: the selected chain is walked down from `S_e`, every chain block's mergeset blues counted once (the `compute_weights` walk of `finality.rs`, bounded by the merge depth), a blue block counted when its DAA score lies in `(daa(S_e) - W, daa(S_e)]`, and signalling when its object byte is at least 4. Share = signalling / total, in basis points.
3. **The decision, per epoch, monotone.** Epoch `e` is class v4 when (a) `L e >= N6`, the floor (the fixed height, rounded up to the epoch boundary exactly as the v3 switch is: `program_class_for_epoch_at`), or (b) epoch `e - 1` was v4, or (c) the window ending at `S_e` is FULL (`daa(S_e) >= W`) and its share is at least 9,500 bps. A signal moves the class one step: the rule answers v4 only where the floor rule answers v3 (below the v3 switch the answer is v2 whatever is signalled). The decision is memoised per seed block, so a fork of the chain has its own entries and one tally is paid per epoch per process. The epoch-boundary rounding of the v2 to v3 switch is unchanged: a class never changes inside an epoch, every block's class is its epoch's.
4. **The window `W`: 86,400 DAA (one day of blocks at 1 block/s), `program_class_v4_signal_window_daa` in the override file, in the digest right after the floor; 0 = signalling off, the floor alone (2.0's rule, byte for byte).** Why a day: the share must mean "the fleet that mines, not the fleet that happens to be up this hour", and a day covers every box's daily pattern (the rented boxes come and go by the hour); it is long enough that 95 percent cannot be reached by a burst and short enough that the flip lands within a day of the last upgrade; the finality rule's 30-day window answers a different question (who may vote) and would hold the class for a month after the fleet was ready. On the fast-time profile `W` is 120 (two epochs of 60).
5. **The threshold: 9,500 bps (95 percent), a constant (`CLASS_SIGNAL_THRESHOLD_BPS`), not a file field**, so no file can lower it; 95 percent of the blue blocks of a day is 95 percent of the hash rate of that day, the coordinator's figure; a box that cannot mine v4 (an old worker, section 2's wire note) is at most 5 percent of the hash rate at the flip, and the floor catches the rest.
6. **The floor `N6`: `program_class_v4_activation_daa`, set at the publish as DAA + 14,400 rounded up to the epoch boundary (the 2.0 rule for N4), checked `N6 - DAA >= 10,800`.** A stalled signal (a fleet that never reaches 95 percent) cannot hold the class forever: at `N6` v4 holds regardless. `never` is allowed in the file and means no floor (the signal alone decides; not for the devnet publish).
7. **What a node reports.** `PowEpochInfo` and the template's `powEpoch` carry `programClassV4SignalWindowDaa`, `programClassV4SignalBps` (the share of the window ending at the SINK, the live tally the next decision is heading for), `programClassSignal` (this node's byte) and `programClassV4SignalEpoch` (the epoch v4 was decided by signal on this chain, when it has been); gRPC fields 20 to 23. The daemon prints `Program class v4 signal window from the override file: W DAA ending at each epoch's seed block, threshold 9500 bps of blue blocks; the fixed height is the floor` beside the floor line, and `Program class v4 by miner signal: epoch E (share X bps over W DAA ending at seed block S, threshold 9500 bps, N of M blue blocks)` once per flip.
8. **The miner.** Nothing: the node builds the template header (the miner varies the nonce), so the signal is the node's binary; the miner takes the class of the epoch and the next from the template as before (`next_program_class` is the next epoch's decision once its seed block is known, else this epoch's class; a boundary that decides otherwise costs one refused pair and one prepare, the 2.0 era-boundary shape). `IGNEUM_CLASS_SIGNAL=<n>` on devnet and simnet only lowers a node's byte (the fast-time gate's non-signalling node); it is not read on mainnet or the testnet.
### 6.2 The devnet object changes shape
Two fields join the live object: `program_class_v4_activation_daa` is now the FLOOR (its name and its rounding unchanged: the 2.0 fixed-height form), and `program_class_v4_signal_window_daa` is the window (86,400 on the devnet; 0 turns signalling off). Both enter the digest (unconditionally, right after the v3 field), so the digest moves on the binary rollout once more; the pinned devnet digest of the fork's test is re-pinned to the value of this binary (section 6.4). The publish object and the rehearsal object are in `docs/plans/counter-asic-3-rehearsal.md` section 2. Defaults: devnet and mainnet 86,400, testnet and simnet 0 (the testnet is v4 from genesis by its floor of 0; the simnet keeps 2.0's rule).
### 6.3 What it does not cover (owed)
| Item | Why | What is done about it |
|---|---|---|
| A class-signal witness in the pruning-proof format | a node that synced from a proof holds no headers below its pruning point, so an epoch whose window reaches below it cannot be tallied; the node then takes the floor rule for that epoch and logs it (`class_signal.rs`), which can disagree with a full-history node for the epochs between a signal flip and the floor | the same class as the era witness of 2.0 section 7 (`MissingEraSeed`); the floor bounds the exposure to at most `N6 - flip`; the witness (the per-epoch decision beside the epoch seed in the proof) is the next node item |
| The first tally after a restart | one walk of `W` chain blocks' mergesets per epoch per process, memoised; a day of blocks is about 86,400 header reads, under a second on the Mac's store | measured in the fast-time runs below at `W` = 120 only; the devnet figure is owed from the rehearsal |
| A byte above 4 | accepted and counted as a v4 signal (a later object contains v4); a v5 rule would count bytes at or above 5 | nothing now |
### 6.4 The fast-time gate (three cases and the failed case)
`infra/fast-time/class-v4-signal.mjs`: three nodes on `override-60x.json` (v3 from DAA 60, window 120, the floor at `--floor`), each node's byte set by `IGNEUM_CLASS_SIGNAL` (`--signal a,b,c`), one real CPU miner each, the id assertion of G4 on every v4 epoch.
SIGNAL_RUNS_PLACEHOLDER

View file

@ -0,0 +1,78 @@
# Counter ASIC 3.0: the class v4 rehearsal on the rented fleet (precondition 1 of the cut)
6 October 2026, worker "ca3-v4-node", on the coordinator's word of about 17:40 UTC. A precondition: no live-devnet date for class v4 is named until this rehearsal has PASSED on the rented fleet. The fleet agent owns the boxes (memory `gpu-rental.md`: the Vast and RunPod accounts, the fleet SSH key, the 6 October fleet plan and budget); this file is the plan it runs and the objects it runs with. The node lane touches no box. Nothing here is published to the live devnet; the rehearsal chain is a fleet-only network that the live devnet's nodes cannot join and that cannot join them.
## 1. What is rehearsed
The class v4 activation on a chain of real boxes, in the shape the live devnet will see, with both activation rules of the v4 seam (`docs/plans/counter-asic-3-node.md`): the miner-signalled flip (section 6 there, PROPOSED) and the fixed-height floor behind it; the stale-box case, where one box runs the old binary against the new object and must be refused at the handshake without forking the chain; and the live form of the G4 checks.
| Fact | Value | Why |
|---|---|---|
| Network | `--devnet-suffix=400` (`igneum-devnet-400`), every box; the live devnet has no suffix and node 1, the observer and the seeds refuse any other network name at the handshake | fleet-only by construction; the chain starts at its own genesis state (DAA 0) because no box carries a devnet-400 database |
| Binary | one commit of ca3-v4-node (main) and ca3-v4-node (fork), the build job's Linux `igneumd`, `igneum-miner`, `igneum-app` from PC 2 (the G6 job of the signalling commit; the shas in `docs/plans/counter-asic-3-gate/node-gates.md`) on every box but the stale one; the stale box runs 0.3.13's Linux node (fork bb43e9a8, the 0.3.13 outputs) | the same objects as the cut's step 1 |
| Boxes | 12 or more mining boxes (the 50-miner wave's shape at a tenth of the size; 1 CPU miner or 1 GPU worker each), 1 seed box (`--listen`, no miner), 1 stale box | 12 keeps the per-box share near 8 percent, so one box's absence moves the tally by 8 percent: the 95 percent threshold is exercised, not trivially met (with 3 boxes it is 67 or 100 percent, the fast-time shape) |
| Override object | `rehearsal` below, the same file on every box (the stale box too) | the stale case is the digest refusal, so the file must be the same |
| Duration | about 2 h 40 min of chain at 1 block/s: the flip by signal at DAA 7,200 (epoch 2), two epochs after it, then the floor at 14,400 is NOT reached (the run ends at DAA 10,800) | the floor is the backstop; the rehearsal proves the signal path, the fast-time gate proved the floor path |
## 2. The override objects
Both objects below are JSON text to be written verbatim; a `never` height is `18446744073709551615`, which no JSON tool that goes through a double may rewrite (the fast-time harness's rule). The digests are what a node of the signalling commit prints at start (`Consensus params digest`); the fleet agent compares every box's line against them and the stale box's against its own.
### 2a. The live devnet publish object (NOT published by this plan; the shape the cut will use)
The live file today (`/tmp/igneum-devnet/override-v3.json`, 13 fields, read 16:55Z) plus the two v4 fields. `N6` is the floor: DAA at the publish + 14,400 rounded UP to a multiple of 3,600 (the 2.0 rule for N4), checked at publish (`N6 - DAA >= 10,800`). At the live DAA of 202,919 (16:57:43Z) that would be 219,600 (epoch 61); the number is set at the publish, not here.
```
{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200,"program_class_v3_activation_daa":154800,"proving_v1_activation_daa":154800,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":198000,"exec_restart_number":27276,"exec_restart_hash":"bb45cf0dd2d7cc97ebfa5a2701527c09a8ede5d32de74efead9caa293b15688a","exec_restart_trust_daa":200000,"program_class_v4_activation_daa":N6,"program_class_v4_signal_window_daa":86400}
```
What the two fields do on the live devnet: every node of the signalling binary stamps object byte 4 into its templates from its first block, so the signal share climbs as the fleet updates; the class flips at the first epoch boundary whose window (the 86,400 DAA, one day, below that epoch's seed block) has 95 percent of its blue blocks signalling, which is about a day after the LAST box of 95 percent of the hash rate has updated; the floor `N6` flips it regardless at the latest. Digest of this object: `ac8e60ce205852bdda6b554f8cbfbd9dbb040187f487cbd8affe8103633dfd56` (read from the signalling node's start line, 18:05Z; the node also prints `Program class v4 from the override file: active from epoch 61 (DAA score 219600 rounded up to the epoch boundary at 219600, epochs of 3600 DAA)` and the window line) (with `N6` = 219,600 as the worked example; any other `N6` moves it).
### 2b. The rehearsal object (the fleet chain)
A fresh chain, every earlier switch at 0 (the testnet's shape: the chain is born on calibrated difficulty v1, proving v1, fees v1, finality v3, class v3), the v4 signal window one epoch, the floor four hours out:
```
{"difficulty_v2_activation_daa":0,"proving_v0_activation_daa":0,"fees_v1_activation_daa":0,"finality_v3_activation_daa":0,"program_class_v3_activation_daa":0,"proving_v1_activation_daa":0,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":0,"exec_restart_number":18446744073709551615,"exec_restart_hash":"","exec_restart_trust_daa":18446744073709551615,"program_class_v4_activation_daa":14400,"program_class_v4_signal_window_daa":3600}
```
The arithmetic: epochs of 3,600 DAA, lead 600. Epoch `e`'s seed block is the last chain block below `3600 e - 600`; its window is full when that block's DAA is at least 3,600: epoch 1's seed block sits at DAA 2,999 (not full), epoch 2's at 6,599 (full). With every mining box signalling 4, the tally at epoch 2's seed block is 100 percent of the blue blocks in DAA 2,999 to 6,599, so the class flips at epoch 2, DAA 7,200, about 2 hours after genesis; the floor (epoch 4, DAA 14,400) is 2 hours later and is not reached by the run. Digest: `bc2142b178ff367ae84ff0699ff523760d8878f883375da21864ed8d3ad39237` (the signalling node's start line on this object, 18:05Z, with `Program class v4 from the override file: active from epoch 4 (DAA score 14400 ...)` and `Program class v4 signal window from the override file: 3600 DAA ...`); the devnet digest with no file at all is `7f2e49beabc253f327c5ac6bb457a674ea7f527af2971c95d3bdf65ef8bcf977` on this binary (the fork's pinned test), `c562d70e...` on 0.3.11 to 0.3.13.
## 3. The steps the fleet agent runs
| Step | What | Done when |
|---|---|---|
| 1 | Fetch the signalling commit's Linux binaries from the G6 build job (the shas in node-gates.md), verify every sha256, place `igneumd` and `igneum-miner` on every box; the 0.3.13 Linux `igneumd` on the stale box | every sha matches |
| 2 | Write the rehearsal object (2b) as `override.json` on every box, byte for byte (sha256 the file on each box and compare) | one sha on every box |
| 3 | Start the seed box: `igneumd --devnet --devnet-suffix=400 --nodnsseed --disable-upnp --listen=0.0.0.0:16411 --rpclisten=127.0.0.1:16410 --rpclisten-json=127.0.0.1:16412 --override-params-file=override.json --utxoindex --enable-unsynced-mining --yes --appdir=<fresh dir>` (ports of the box's choosing, never the live devnet's 26610/26611); read its first lines: `Consensus params digest` equals 2b's, `Program class v4 from the override file: active from epoch 4 (DAA score 14400 ...)`, `Program class v4 signal window from the override file: 3600 DAA ...` | the three lines |
| 4 | Start every mining box the same way with `--connect=<seed>:16411`, then its miner: `igneum-miner mine grpc://127.0.0.1:16410 1 100000000 <label> --engine igneum-pow --no-vote --payout-label <label>` (a CPU miner; a GPU box uses `--worker <path> --prepare-packs packs/prepare --exit-on-seed-change`, the app's shape) | every box's node prints the same digest and the two switch lines; every miner prints `epoch seed ... class v3 program id ...` for epoch 0 |
| 5 | Start the stale box last, the same command on the 0.3.13 node, `--connect=<seed>:16411` | its log shows the handshake refusal (a digest mismatch line or `0 peers` after 60 s with connection attempts in the log) and its chain stays at its own genesis (block count 1 or its own lonely blocks if it mines; it must NOT mine: no miner on it) |
| 6 | Every 15 minutes, on every mining box: `getBlockDagInfo` (block count, sink, virtual DAA) and one `getBlockTemplate` (`powEpoch.programClass`, `nextProgramClass`, `programClassV4SignalBps`, `programClassV4SignalEpoch`); keep the lines | the shares read 10,000 bps on every box from the first template; sinks agree across boxes at each sample |
| 7 | At DAA 7,200 (about 2 h): every box's node prints `Program class v4 by miner signal: epoch 2 (share 10000 bps over 3600 DAA ending at seed block <hash>, threshold 9500 bps, N of N blue blocks)` with the SAME seed block hash and the same N on every box; the templates read class 4 from epoch 2; every miner prints `epoch seed <S2> ... class v4 program id <id>` | the same `<S2>`, the same `<id>` on every box |
| 8 | Run to DAA 10,800 (epoch 3, one epoch after the flip; two epochs after is 14,400, the floor, so the run stops at 10,800 plus 600) | the final sample |
| 9 | Collect every miner's `program and 256 MiB cache ready` lines (seed, class, id) and the template's `eraSeed` for epochs 2 and 3, and send them to the node lane; the Mac computes `igneum-pow show --epoch-hex <seed> --program-class v3|v4 --era-hex <era>` for each (the id assertion of the G4 harness, no binary needed on a box) | every box's v4 id equals the CLI's v4 id and differs from the CLI's v3 id of the same seed and era |
| 10 | Stop every node and miner; destroy the instances by the fleet plan's rule | the report in section 4 is in |
Never: no live-devnet port, no live override file, no manifest, no `update-now`; the boxes' app installs (if any) are not touched (the rule of 5 October: a job never quits or restarts an app it did not start).
## 4. What the fleet agent reports back (one table, one JSON)
`docs/plans/counter-asic-3-gate/rehearsal-<time>.json` with the fields below and a row per box in `docs/plans/counter-asic-3-rehearsal.md` section 5 (this file, appended by the fleet agent):
| Field | From | Pass rule |
|---|---|---|
| `digest` per box | the node's first lines | one value on every signalling box, equal to 2b's; the stale box prints 0.3.13's digest of the same file (a different value, since its binary lacks the two fields) |
| `switch_lines` per box | the node's first lines | the floor line names epoch 4 and the window line names 3,600 DAA, on every signalling box |
| `signal_line` per box | step 7 | present on every signalling box, epoch 2, the same seed block hash, share 10,000 bps (at least 9,500) |
| `blocks_before`, `blocks_after` | `getBlocks` from any box, split at DAA 7,200 | both over 0 |
| `rejected` | every miner's `rejected=` STATUS count and every node's `PoW rejected` lines | 0 on every box |
| `sinks` at the end | `getBlockDagInfo` | one value on every signalling box |
| `program_ids` per epoch per box | the miners' lines | one id per epoch across boxes; epochs 2 and 3 class v4; the Mac's CLI check (step 9) holds |
| `stale_box` | its log and `getBlockDagInfo` | refused (no peer), block count 1, no block of its ever appears on any signalling box's chain (its own genesis-state chain and the fleet's never merge) |
| `shares` per sample | step 6 | 10,000 bps on every box at every sample |
PASS = every rule above holds. Any other outcome is RED, the file says which rule failed, and the cut waits.
## 5. Results
(appended by the fleet agent after the run)

View file

@ -69,6 +69,7 @@ Time parameters, divided by 60 (devnet value, 60x value):
| `proving_v1_unproven_daa` | 600 | 10 | a DAA clock (the unproven allowance), divided by 60; the proving agent confirms the value |
| `program_class_v3_activation_daa` | never | never | a height, not a clock: the lottery hash draws programs from class v3 (Counter ASIC 2.0, 5 Oct 2026) from the first EPOCH whose start is at or above this DAA score (rounded up to an epoch boundary: at 60 DAA per epoch, 150 means epoch 3 at DAA 180); `infra/fast-time/class-v3.mjs` sets it a few epochs ahead in its merged file |
| `program_class_v4_activation_daa` | never | never | a height, not a clock: the lottery hash draws programs from class v4 (Counter ASIC 3.0, 6 Oct 2026: class v3 plus the latency-shadow block) from the first EPOCH whose start is at or above this DAA score, rounded up like the v3 switch; `infra/fast-time/class-v4.mjs` sets it a few epochs ahead in its merged file |
| `program_class_v4_signal_window_daa` | 86,400 | 120 | a DAA window (one day of blocks), divided by 60 and rounded to two epochs: the class v4 signal tally (PROPOSED, `docs/plans/counter-asic-3-node.md` section 6) over the blue blocks below each epoch's seed block; 0 = off; `infra/fast-time/class-v4-signal.mjs` is its gate |
| `proving_v1_fresh_rule_daa`, `exec_restart_number`, `exec_restart_trust_daa`, `exec_restart_hash` | never, never, never, "" | the same | heights and a hash, not clocks (the 0.3.12 and 0.3.13 switches); present so the fork's every-field test (`fast_time_60x_file_is_the_devnet_at_60x`) holds; added 6 Oct 2026 with the class v4 field |
Unchanged, and why:

View file

@ -0,0 +1,274 @@
#!/usr/bin/env node
// Counter ASIC 3.0, miner-signalled class activation (PROPOSED, docs/plans/counter-asic-3-node.md section 6): the
// fast-time 3-node network where each node signals an object version of its own (IGNEUM_CLASS_SIGNAL, devnet only),
// so the class v4 decision is made by the miners' blue-block share over the signal window, not by a fixed height.
// Ports 29690 and up, network igneum-devnet-969, data /tmp/igneum-fast-time-v4s; the class-v4.mjs shape otherwise:
// override-60x.json with CPU genesis bits, v3 from --v3-activation (default 60, epoch 1), the v4 floor at --floor
// (default never), the window at --window (default 120 DAA, two epochs; the first epoch whose seed block has DAA >= 120
// is epoch 3, seed at DAA 169), one real CPU miner per node.
//
// The three cases and the known-failed case:
// --signal 4,4,3 --expect no-flip two of three miners signal: 67 percent, the class must stay v3 (run 7 epochs)
// --signal 4,4,4 --expect flip all three: it flips at epoch 3, the first boundary with a full window
// --signal 3,3,3 --floor 300 --expect floor nobody signals: it flips at the floor (epoch 5, DAA 300) and not before
// --signal 4,4,3 --expect flip the known-failed case: the harness must report FAIL (no flip happened)
//
// node infra/fast-time/class-v4-signal.mjs --signal a,b,c --expect flip|no-flip|floor [--floor <daa>|never]
// [--window 120] [--v3-activation 60] [--secs 480] [--epochs 7]
// IGNEUMD, IGNEUM_MINER, IGNEUM_POW name the binaries (defaults: the ca3 fork worktree's target-ca3v4/release and
// igneum-pow/target/release/igneum-pow).
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
import { devAddress } from '../../tools/harness/lib/address.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const BIN = process.env.IGNEUM_CA3_BIN || `${ROOT}vendor/igneum-node-ca3v4/target-ca3v4/release`;
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
const IGNEUM_POW = process.env.IGNEUM_POW || `${ROOT}igneum-pow/target/release/igneum-pow`;
const TMP = '/tmp/igneum-fast-time-v4s';
const BASE = 29690, SUFFIX = 969;
const NEVER = '18446744073709551615';
const args = process.argv.slice(2);
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? +args[i + 1] : dflt; };
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
const actflag = (name, dflt) => { const v = sflag(name); if (v == null) return dflt; return v === 'never' ? null : +v; };
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
const SECS = flag('secs', 480);
const EPOCHS = flag('epochs', 7);
const FLOOR = actflag('floor', null);
const V3_ACTIVATION = actflag('v3-activation', 60);
const WINDOW = flag('window', 120);
const SIGNAL = (sflag('signal') || '4,4,4').split(',').map(Number);
const EXPECT = sflag('expect') || 'flip';
if (!['flip', 'no-flip', 'floor'].includes(EXPECT) || SIGNAL.length !== 3) { console.error('usage: --signal a,b,c --expect flip|no-flip|floor'); process.exit(2); }
const started = [];
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const baseText = readFileSync(FILE, 'utf8');
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
const EPOCH = field('pow_epoch_blocks');
const LEAD = field('pow_epoch_lead');
const DAY_MS = field('pow_day_ms');
const FIRST_V3_EPOCH = V3_ACTIVATION == null ? null : Math.ceil(V3_ACTIVATION / EPOCH);
const FLOOR_EPOCH = FLOOR == null ? null : Math.ceil(FLOOR / EPOCH);
// the first epoch whose seed block (the last chain block below L*e - lead) can have DAA >= WINDOW: L*e - lead - 1 >= WINDOW
let FIRST_FULL_EPOCH = 0;
while (FIRST_FULL_EPOCH * EPOCH - LEAD - 1 < WINDOW) FIRST_FULL_EPOCH++;
export function mergeOverrideText(text, fields) {
let out = text;
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
}
const asText = (v) => v == null ? NEVER : String(v);
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, { genesis_bits: GENESIS_BITS, skip_proof_of_work: false, program_class_v3_activation_daa: asText(V3_ACTIVATION), program_class_v4_activation_daa: asText(FLOOR), program_class_v4_signal_window_daa: String(WINDOW) }));
log(`signals ${SIGNAL.join('/')}, expect ${EXPECT}; v3 from ${V3_ACTIVATION ?? 'never'} (epoch ${FIRST_V3_EPOCH ?? 'none'}), v4 floor ${FLOOR ?? 'never'} (epoch ${FLOOR_EPOCH ?? 'none'}), window ${WINDOW} DAA (the first epoch with a full window is ${FIRST_FULL_EPOCH}); ${EPOCH} DAA per epoch, lead ${LEAD}; run ${SECS} s or ${EPOCHS} epochs`);
class Node {
constructor(i, connect = []) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2;
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`,
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
// the node's own object byte: what its templates signal
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_CLASS_SIGNAL: String(SIGNAL[this.i]) } });
started.push(this.proc);
await sleep(1200);
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}, signals ${SIGNAL[this.i]}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
function miner(bin, argv, name, env = {}) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
started.push(p);
return p;
}
async function stopAll() {
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
const minerLog = (i) => { try { return readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n'); } catch { return []; } };
const SIGNAL_LINE = /Program class v4 by miner signal: epoch (\d+) \(share (\d+) bps/;
const FLOOR_LINE = /Program class v4 from the override file/;
const WINDOW_LINE = /Program class v4 signal window from the override file/;
const OWN_LINE = /Program class signal from IGNEUM_CLASS_SIGNAL: this node signals object version (\d+)/;
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const n0 = await new Node(0).start();
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start();
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start();
const nodes = [n0, n1, n2];
for (const n of nodes) log(`n${n.i}: ${n.grepLog(WINDOW_LINE).map(l => l.replace(/^.*?(Program class v4 signal window)/, '$1'))[0] || '(no window line)'} | ${n.grepLog(OWN_LINE).map(l => l.replace(/^.*?(this node signals)/, '$1'))[0] || '(no signal line)'}`);
log(`n0 digest: ${n0.grepLog(/Consensus params digest/).map(l => l.replace(/^.*?digest: /, '').slice(0, 16)).join(' ')}`);
nodes.forEach((n, i) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', `cpu${i}`, '--status-secs', '30', '--no-vote'], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) }));
const pay = devAddress('fast-time-v4s');
const epochs = new Map();
let firstV4 = null, lastEpoch = -1, lastReport = 0, lastDaa = 0, endAt = null;
const samples = [];
while (Date.now() - t0 < SECS * 1000) {
await sleep(1000);
let daa = null, epoch = null, cls = null, nextCls = null, eraSeed = null, bps = null, win = null, sig = null, sigEpoch = null;
try {
const t = await n0.rpc.call('getBlockTemplate', { payAddress: pay, extraData: [] });
const pe = t.powEpoch || t.pow_epoch || {};
daa = pe.virtualDaaScore ?? t.block?.header?.daaScore; epoch = pe.epochIndex; cls = pe.programClass; nextCls = pe.nextProgramClass;
eraSeed = pe.eraSeed; bps = pe.programClassV4SignalBps; win = pe.programClassV4SignalWindowDaa; sig = pe.programClassSignal; sigEpoch = pe.programClassV4SignalEpoch;
} catch (e) { log(`template: ${e.message}`); }
if (epoch != null && epoch !== lastEpoch) {
epochs.set(epoch, { class: cls, firstSeenDaa: daa, at: +since(), eraSeed: eraSeed == null ? null : String(eraSeed), bps, signal_epoch: sigEpoch ?? null });
log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s: template class ${cls}, next ${nextCls}, signal share at the sink ${bps} bps (window ${win}, this node signals ${sig}, decided by signal at epoch ${sigEpoch ?? 'none'})`);
if (firstV4 == null && cls === 4) { firstV4 = { epoch, daa, at: +since() }; log(`CLASS SWITCH: the template is class v4 from epoch ${epoch} (daa ${daa}) at ${since()} s wall`); }
lastEpoch = epoch;
}
lastDaa = daa ?? lastDaa;
if (Date.now() - lastReport > 15000) {
lastReport = Date.now();
const counts = await Promise.all(nodes.map(async n => { try { const d = await n.rpc.call('getBlockDagInfo'); return `${d.blockCount}/${String(d.sink).slice(0, 8)}`; } catch { return '?'; } }));
log(`t=${since()} s daa ${daa} epoch ${epoch} class ${cls} signal ${bps} bps blocks/sink per node ${counts.join(' ')}`);
samples.push({ t: +since(), daa, epoch, class: cls, bps, nodes: counts });
}
// the end: two epochs after a flip, or --epochs epochs when no flip is expected
if (firstV4 != null && daa != null && daa >= (firstV4.epoch + 2) * EPOCH) { endAt = +since(); break; }
if (firstV4 == null && daa != null && daa >= EPOCHS * EPOCH) { endAt = +since(); break; }
}
await sleep(3000);
const dag = await Promise.all(nodes.map(async n => { try { return await n.rpc.call('getBlockDagInfo'); } catch (e) { return { error: e.message }; } }));
const genesis = dag[0].pruningPointHash;
async function allBlocks(n) {
const out = []; let low = genesis; const seen = new Set();
for (let round = 0; round < 500; round++) {
const r = await n.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: false });
const blocks = r.blocks || [];
let added = 0;
for (const b of blocks) { const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h); out.push({ hash: h, daa: +b.header.daaScore, version: +b.header.version, chain: !!b.verboseData?.isChainBlock }); added++; }
if (!blocks.length || added === 0) break;
low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break;
}
return out;
}
let blocks = [];
try { blocks = await allBlocks(n0); } catch (e) { log(`getBlocks: ${e.message}`); }
const BOUNDARY = firstV4 ? firstV4.epoch * EPOCH : Infinity;
const before = blocks.filter(b => b.daa < BOUNDARY), after = blocks.filter(b => b.daa >= BOUNDARY);
// the signal bytes on the chain: the share of blocks whose version high byte is 4
const versionBytes = blocks.reduce((m, b) => { const v = b.version >> 8; m[v] = (m[v] || 0) + 1; return m; }, {});
const signalShareOnChain = blocks.length ? Math.round(10000 * (blocks.filter(b => (b.version >> 8) >= 4).length) / blocks.length) : 0;
const programs = new Map();
for (const i of [0, 1, 2]) for (const l of minerLog(i)) {
const m = /epoch seed ([0-9a-f]{64}) day (\d+) \(daa (\d+)\): program and 256 MiB cache ready in ([\d.]+) ms; class (v\d) program id ([0-9a-f]{16})/.exec(l);
if (!m) continue;
const k = m[1]; const e = programs.get(k) || { seed: k.slice(0, 16), epoch: Math.floor(+m[3] / EPOCH), class: m[5], id: m[6], miners: new Set() };
if (e.id !== m[6] || e.class !== m[5]) e.disagree = true;
e.miners.add(i); programs.set(k, e);
}
const programRows = [...programs.values()].sort((a, b) => a.epoch - b.epoch).map(p => ({ epoch: p.epoch, class: p.class, program_id: p.id, seed: p.seed, miners: p.miners.size, disagree: !!p.disagree }));
function cliId(seedHex, eraHex, cls) {
if (!existsSync(IGNEUM_POW)) return null;
const r = spawnSync(IGNEUM_POW, ['show', '--epoch-hex', seedHex, '--program-class', cls, '--era-hex', eraHex], { encoding: 'utf8' });
const m = /program id ([0-9a-f]{16})/.exec(r.stdout || '');
return m ? m[1] : null;
}
const idRows = [];
for (const [k, e] of programs) {
if (e.class !== 'v4') continue;
const era = epochs.get(e.epoch)?.eraSeed;
idRows.push({ epoch: e.epoch, seed: e.seed, miners_id: e.id, miners: e.miners.size, cli_v3: era ? cliId(k, era, 'v3') : null, cli_v4: era ? cliId(k, era, 'v4') : null });
}
const accepted = [0, 1, 2].map(i => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length);
const rejectedMiner = [0, 1, 2].map(i => minerLog(i).filter(l => /rejected nonce=|submit error/.test(l)));
const rejectedNode = nodes.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i));
const signalLines = nodes.map(n => n.grepLog(SIGNAL_LINE).map(l => l.replace(/^.*?(Program class v4 by miner signal)/, '$1'))[0] || null);
const signalEpochs = signalLines.map(l => { const m = l && SIGNAL_LINE.exec(l); return m ? +m[1] : null; });
const signalShares = signalLines.map(l => { const m = l && SIGNAL_LINE.exec(l); return m ? +m[2] : null; });
const floorLines = nodes.map(n => n.grepLog(FLOOR_LINE).map(l => l.replace(/^.*?(Program class v4 from)/, '$1'))[0] || null);
const sinks = dag.map(d => String(d.sink || '?').slice(0, 16));
const counts = dag.map(d => d.blockCount ?? '?');
const maxEpochSeen = Math.max(-1, ...epochs.keys());
const classesSeen = [...epochs.values()].map(e => e.class);
const common = {
zero_rejected_by_miners: rejectedMiner.every(r => r.length === 0),
zero_rejected_by_nodes: rejectedNode.every(r => r.length === 0),
sinks_agree: new Set(sinks).size === 1,
block_counts_agree: new Set(counts.map(String)).size === 1,
miners_agree_on_every_program: programRows.every(p => !p.disagree),
window_line_on_every_node: nodes.every(n => n.grepLog(WINDOW_LINE).length > 0),
every_node_signals_its_byte: nodes.every((n, i) => n.grepLog(OWN_LINE).some(l => +OWN_LINE.exec(l)[1] === SIGNAL[i])),
// every block's byte is one of the three nodes' (genesis, made before any node, is the one byte-0 block)
chain_carries_the_bytes: blocks.length > 0 && Object.keys(versionBytes).every(v => SIGNAL.includes(+v) || (+v === 0 && versionBytes[v] === 1)),
};
let checks;
if (EXPECT === 'flip') {
checks = {
...common,
template_switched_to_v4: firstV4 != null,
switched_at_the_first_full_window_epoch: firstV4 != null && firstV4.epoch === FIRST_FULL_EPOCH,
switched_before_the_floor: firstV4 != null && (FLOOR_EPOCH == null || firstV4.epoch < FLOOR_EPOCH),
signal_line_on_every_node_same_epoch: signalEpochs.every(e => e != null) && new Set(signalEpochs).size === 1 && signalEpochs[0] === (firstV4 && firstV4.epoch),
signal_share_at_or_above_threshold: signalShares.every(s => s != null && s >= 9500),
blocks_on_both_sides: before.length > 0 && after.length > 0,
v4_ids_equal_the_cli_v4_id: idRows.length > 0 && idRows.every(r => r.cli_v4 != null && r.cli_v4 === r.miners_id && r.miners === 3),
v4_ids_differ_from_the_same_seed_v3_id: idRows.length > 0 && idRows.every(r => r.cli_v3 != null && r.cli_v3 !== r.miners_id),
};
} else if (EXPECT === 'no-flip') {
checks = {
...common,
template_never_v4: firstV4 == null && !classesSeen.includes(4),
no_signal_line_on_any_node: signalLines.every(l => l == null),
ran_the_epochs: maxEpochSeen >= EPOCHS - 1,
v3_programs_seen: programRows.some(p => p.class === 'v3'),
signal_share_under_threshold_on_chain: signalShareOnChain < 9500,
};
} else {
checks = {
...common,
template_switched_to_v4: firstV4 != null,
switched_at_the_floor_epoch: firstV4 != null && firstV4.epoch === FLOOR_EPOCH,
no_signal_line_on_any_node: signalLines.every(l => l == null),
floor_line_names_the_floor_epoch: floorLines.every(l => l && l.includes(`active from epoch ${FLOOR_EPOCH} `)),
blocks_on_both_sides: before.length > 0 && after.length > 0,
v4_ids_equal_the_cli_v4_id: idRows.length > 0 && idRows.every(r => r.cli_v4 != null && r.cli_v4 === r.miners_id && r.miners === 3),
};
}
const pass = Object.values(checks).every(Boolean);
const summary = {
pass, expect: EXPECT, signals: SIGNAL, checks, window: WINDOW, floor: FLOOR ?? 'never', v3_activation: V3_ACTIVATION ?? 'never', epoch_blocks: EPOCH, lead: LEAD, first_full_window_epoch: FIRST_FULL_EPOCH, floor_epoch: FLOOR_EPOCH,
node: IGNEUMD, miner: CPU_MINER, template_switch: firstV4, run_ended_at_s: endAt, final_daa: lastDaa, max_epoch_seen: maxEpochSeen,
epochs: Object.fromEntries([...epochs.entries()].map(([k, v]) => [k, v])),
blocks: { total: blocks.length, before_boundary: before.length, after_boundary: after.length, version_bytes: versionBytes, signal_share_bps_on_chain: signalShareOnChain },
programs: programRows, program_id_rows: idRows, accepted_per_miner: accepted,
rejected_by_miners: rejectedMiner.map(r => r.length), rejected_by_nodes: rejectedNode.map(r => r.length),
sinks, block_counts: counts, signal_lines: signalLines, floor_lines: floorLines, samples,
};
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}, signals ${SIGNAL.join('/')}): ${firstV4 ? `v4 from epoch ${firstV4.epoch} at DAA ${firstV4.daa}` : 'no v4 epoch'}; epochs seen ${[...epochs.entries()].map(([e, v]) => `e${e}:${v.class}:${v.bps}bps`).join(' ')}; chain bytes ${JSON.stringify(versionBytes)} (${signalShareOnChain} bps signal v4); blocks ${before.length} / ${after.length}; rejected miners ${rejectedMiner.map(r => r.length).join('/')} nodes ${rejectedNode.map(r => r.length).join('/')}; sinks ${sinks.join(' ')} (${checks.sinks_agree ? 'agree' : 'DIFFER'}); counts ${counts.join('/')}; signal lines ${signalLines.filter(Boolean).length}/3 (epochs ${signalEpochs.join('/')}, shares ${signalShares.join('/')}); floor lines ${floorLines.filter(Boolean).length}/3`);
for (const r of idRows) log(`PROGRAM ID epoch ${r.epoch} seed ${r.seed}: miners ${r.miners_id} (${r.miners} of 3) cli v4 ${r.cli_v4} cli v3 ${r.cli_v3}`);
for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`);
log(`summary: ${TMP}/summary.json`);
await stopAll();
process.exit(pass ? 0 : 1);

View file

@ -56,6 +56,7 @@
"finality_v3_activation_daa": 18446744073709551615,
"program_class_v3_activation_daa": 18446744073709551615,
"program_class_v4_activation_daa": 18446744073709551615,
"program_class_v4_signal_window_daa": 120,
"proving_v1_fresh_rule_daa": 18446744073709551615,
"exec_restart_number": 18446744073709551615,
"exec_restart_hash": "",