Finality attacks: hostile test network of our own nodes, seven scenarios, bench-log entry
tools/finality-attacks: run.mjs drives a private igneum-devnet-800 network (ports 27800+, /tmp/igneum-fin-attacks, skip_proof_of_work) with the test-only hostile flags of igneum-miner (vmine, --equivocate, --sybil, --drop-votes, --pulse, fin-rpc-attack; worktree fin-attacks on master c6d47547..2a00ff55). Seven scenarios in priority order 3,2,1,6,4,8,5 with a spec 03 criterion and a measured result each; README carries the catalogue, what needs a finality-aware p2p probe, and a proposed diff for every FAIL. Results (six voters): S3 dishonest aggregators PASS (35/35/35 locks, 0 conflicts, 1,018 ms); S2 Sybil dust: weights PASS, aggregator sortition FAIL (per key, ledger F17); S1 equivocation PASS (2/2/2 stripped, 0 conflicts); S6A 3/3 partition FAIL (floor is time-bounded, one side crossed 56.7% at 84 s of a 90 s split, T* = 2F/13R, 9.2 days for a 50/50 split at mainnet scale); S6B 4/2 PASS; S4 vote-dropping producer PASS (0 ms added); S8 malformed votes over RPC PASS (9 cases, no crash); S5 pulse: no retarget amplification (ratio 0.999) PASS, lock-alone FAIL (a 20 s burst locked checkpoints 1 to 10 alone on a young window, ledger F1, spec 3.8 not implemented). S7 eclipse not run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
e70e3a58fc
commit
5962a655c1
5 changed files with 832 additions and 0 deletions
|
|
@ -421,3 +421,28 @@ PC data (`node tools/logs.mjs <run_id> --all`, STATUS lines deduplicated by time
|
|||
Code audit: nothing allocated per job survives the job in `proto-cuda/host.cu`, `proto-opencl/host.c` or `proto-metal/main.swift` serve loops (tables in the analysis); the miner's only per-job growth is time in `Seeder::seeds_for` (memo keyed by `(epoch, sink)`, one `getBlock` RPC per block from the sink to the epoch start on every miss, 1,274 to 3,313 calls on the PC). `cudaDeviceSynchronize` at the default schedule spins one thread per worker (the project lead's 6.2% per process); the hot-swap working tree sets `cudaDeviceScheduleBlockingSync` and swaps `clFinish` for `clWaitForEvents`.
|
||||
Metal runs (STATUS every 30 s; "gap" = 1 minus wall over inside, per interval): R1 control, epoch 0, genesis bits 0x1d100000, 308 s (cut by the 22:21:37 UTC SIGTERM of every process of this session): first interval 25.18 MH/s alone on the GPU, then 14.0 to 14.4 MH/s in every interval after another agent's worker joined at 25 s, gap 0 to 2%, worker RSS 56.8 MiB flat. R2 walk reproduction, 900 s: `skip_proof_of_work` node pumped to DAA 4,000 (one-second timestamps, difficulty held at 76.8M), one identity, pumped blocks at 1/s for 300 s, none for 300 s, 1/s for 300 s: inside 27.0 to 27.7 MH/s in all 29 intervals; wall 22.3 to 24.3 (gap 12 to 18%, walk 400 to 700), 25.9 to 27.3 (gap 0 to 4%), 18.0 to 21.0 (gap 25 to 35%, walk 700 to 1,000); miner CPU 0 to 1% in the quiet phase, 11 to 21% in the last. R3 one worker at difficulty 2^25 (Kaspa sampled rule, genesis bits held), 600 s: 30.51 wall / 30.72 inside, 1,091 jobs, 224 blocks, 53 to 56 jobs per 30 s throughout. R4 eight workers at 2^25: 29.38 / 29.45 summed (3.32 to 4.38 each), 1,053 jobs, 242 blocks, 7 jobs per 100 s per identity in every interval, worker CPU 0.0 to 0.6%, RSS 46 to 57 MiB. R5 one worker at 2^31: 37.01 / 37.75, 1,324 jobs, 4 blocks, flat. R6 eight workers at 2^31: 36.67 / 36.75 summed (4.29 to 5.55 each), 1,314 jobs, 5 blocks, flat. (R5 and R6 ran a different epoch-0 program from R3 and R4, 112 loads per hash, hence 37 against 30.5 MH/s.)
|
||||
Side findings: the `difficulty` worktree's node panics at `consensus/src/processes/difficulty.rs:431` ("Work should not exceed 2**192") when fed 85 blocks/s with wall-clock timestamps under the Igneum dual rule (a pump artefact, logged for the consensus-engineer); `skip_proof_of_work` nodes still log "PoW rejected ... by igneum-lottery-v1-bound" for every block they accept. Not done: the fix applied and measured on the PC (the acceptance figure is a flat gap at DAA 10,800 with eight identities); the OpenCL event wait checked on the AMD driver; a unit test of `seeds_for` (the client is concrete).
|
||||
|
||||
## 2026-10-04 finality v2 attack harness: seven hostile scenarios on a six-voter private test network (consensus test engineer, cryptographer)
|
||||
|
||||
Machine: Apple M5 Max, rustc stable, macOS Darwin 25.6.0. Fork: worktree `vendor/igneum-node-fin-attacks`, branch `fin-attacks` on master `c6d47547` to `2a00ff55` (BLS votes, certificates in coinbase extra data, p2p message 70, the finality RPCs). Build: `CARGO_TARGET_DIR=target nice -n 19 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow`. Tool: `tools/finality-attacks` (`run.mjs`, `lib/`, README with the proposed fixes). Network: `igneum-devnet-800`, ports 27800 and up, data `/tmp/igneum-fin-attacks`, `skip_proof_of_work` (the hostile miners never hash; each gets its block share from a Poisson clock; every other consensus rule unchanged). Devnet finality parameters: interval 30, depth 20, weight window 7,200 DAA, dust 5, presence 20 indices, 8 aggregators, ban 7,200 DAA, quorum 2/3 of active and 17/30 of total. Durations at SCALE 0.6. The live devnet (26610, 26611, 26640, 26641, 28640) was never touched. Run time 32 min of process time (the machine slept twice during the run, which pauses the monotonic clocks the harness and the miners use, so wall-clock timestamps in the log jump; no result depends on wall time).
|
||||
|
||||
Hostile pieces are test-only flags of `igneum-miner`, never honest node or consensus code: `vmine` (Poisson submit at a chosen hash share, decoupled 0.5-s voter), `--equivocate`, `--sybil b:bb:a:ab` (one miner mints many vote keys), `--drop-votes` (strips the node's finality section from its coinbase so its blocks carry no votes or certificates while it still votes over RPC), `--pulse burst:on:period`, and `fin-rpc-attack` (malformed, mis-signed, replayed, oversized and non-hex votes over `submitFinalityVote`). Six voters throughout; three nodes for the cross-node scenarios, two nodes over a TCP proxy for the partitions.
|
||||
|
||||
| # | Scenario (priority order) | Criterion (spec 03) | Measured | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| 3 | Dishonest aggregators (6 voters, 3 nodes, every node aggregates) | other aggregators' certificates still lock; a sub-quorum certificate cannot lock (Q3); block-carried votes give participation (F3); lock latency under 2 s median | 35 / 35 / 35 locked per node, identical lock hashes on all three, 0 conflicting certificates, median lock latency 1,018 ms (bounded by the miners' 1-s poll). Sub-quorum rejection is by code review (`lock_test` needs both integer tests; a certificate below either is `Certified`, never `Locked`); injecting one on the wire needs a finality-aware p2p probe (not built) | PASS (wire injection not run) |
|
||||
| 2 | Sybil dust (one miner mints 200 keys at 4 blocks and 200 at 6, dust 5; 3 honest voters) | dust keys zero weight and no voters; above-dust weight = blocks; total weight = voters' blue blocks; sortition by weight not key count (F17) | 200 dust keys seen, all `voter false`; 203 voters above dust; total weight 1,240 = sum of voter blocks 1,240; aggregator sortition is PER KEY (`is_aggregator(output, voters, 8)` counts keys), with 203 voters a real signer's chance to be an aggregator fell to about 8/203 and the last checkpoint named 0 aggregators (zero-aggregator certificates, "anyone MAY aggregate") | weights PASS; sortition FAIL (F17) |
|
||||
| 1 | Equivocation at scale (2 of 6 keys sign two checkpoints at every index, 3 nodes) | both keys stripped within one checkpoint on every node; no conflicting certificate; honest locks continue | stripped keys 2 / 2 / 2 on the three nodes, 78 / 8 / 8 detections (node-local on the equivocators' node, block-carried evidence on the others), 0 conflicting certificates, 35 / 35 / 35 locks by the 4 honest keys | PASS |
|
||||
| 6A | Partition 3/3 for 90 s after a 252-s shared warmup (window 1,439 DAA at the cut), then heal | zero locks on either side during the split; locks resume after the heal; no conflicting certificates | side 0: no new lock in 90 s; side 1: first new lock at 84 s, 8 locks before the heal; 0 conflicting certificates (side 0 never locked those indices); locks resumed on both sides after the heal. Side 1 crossed the floor because its own fresh blocks raised its share of its window: at the cut each side held 50% of 1,439 DAA of weight; the 3-miner side added about 2.6 blocks/s and by 84 s held (720 + 220) / (1,439 + 220) = 56.7%. The model is share(T) = (F/2 + R T) / (F + R T) with F the window weight at the cut and R the side's block rate, so the floor holds for T* = 2F / (13R): 74 s predicted at F = 1,439 and R = 3, 84 s measured (sibling losses lower R). The simulation used fixed weights and could not see this (spec 3.7 item 8) | FAIL (floor is time-bounded) |
|
||||
| 6B | Partition 4/2 for 90 s after a 252-s warmup, then heal | the 4 side (66.7% of total) keeps locking; the 2 side (33%) does not; no conflicting certificates | 4 side locked 47 to 59 (first new lock 15 s after the cut, the active test passes at exactly 2/3); 2 side stayed at 47; 0 conflicting certificates; both resumed after the heal | PASS |
|
||||
| 4 | Vote-dropping block producer (40% of blocks carry no finality section, 2 nodes) | participation and locks unaffected because other blocks carry the votes; delay measured | the node that saw the dropper's blocks only through gossip and the other producers' blocks locked 35 checkpoints; median lock latency 1,019 ms with the dropper vs 1,019 ms control, 0 ms added | PASS |
|
||||
| 8 | Malformed votes over the RPC (9 cases, fresh key per case) | rejected without a crash; node stays up | control vote accepted; replay answered "already known" (deduplicated, not double-counted); bad signature and wrong chain id rejected "invalid vote signature"; a vote for a hash the node does not hold at a known index is recorded and flagged, not certified; 8-byte, 2 MB and non-hex payloads rejected "vote must be 280 bytes" / "vote is not hex" before any processing; node answered `getInfo` after all 9. The message-70 half (sub-quorum and replayed certificates, oversized bitmaps) needs the p2p probe; by code review `Certificate::read` bounds the bitmap at 1 MB, the relay bounds a message at 1 MB and a malformed one is a `ProtocolError` that disconnects the peer | PASS (RPC half) |
|
||||
| 5 | Pulsed miner (base share 1/6, 10x for 20 s of every 120 s, 5 steady voters, 216 s, Kaspa's DAA rule as master runs it) | weight proportional to block share over the window (no retarget amplification, W2 and F14); cannot lock alone | weight share 35.3% vs block share 35.3%, ratio 0.999: W2 counts blocks and the retarget lag bought nothing extra. But checkpoints 1 to 10 were locked by the burster ALONE: its first 20-s burst gave one key 66.7% to 71.7% of a window that held under 300 blocks (cp 5: 98 of 147 signed by 1 of 6 voters; cp 10: 201 of 297), above both Q3 tests. From cp 11 every lock needed 3 or 4 signers as its share decayed to 35%. This is ledger F1 measured live: with no first-month gate (`min_daa` 0 on devnet, 3,600 DAA on mainnet, spec 3.8 not implemented) a short burst owns a young window | amplification PASS; lock-alone FAIL (F1) |
|
||||
| 7 | Eclipse of one voter with an adversarial side chain | not run: needs the finality-aware p2p probe to feed a private fork | not measured | not run |
|
||||
|
||||
Failures and the proposed fixes (diffs in `tools/finality-attacks/README.md`, for gate 3 to ratify; no rule was changed here):
|
||||
1. F17 (S2): `is_aggregator` draws the 8 aggregators per key. Liveness only, because any node may aggregate and a certificate must still meet Q3 by weight, which the Sybil split does not change. Proposed: draw by weight, `output x total < 8 x weight x 2^64`, mirroring the spec 7.2 step-2 fix.
|
||||
2. Floor time bound (S6A): the 56.7% floor protects a partition for about T* = 2F / (13R) of DAA time, with F the window weight at the cut and R the majority side's block rate. Approximate, formula only, window slide ignored: on mainnet with a full 30-day window and a 50/50 split at 1 block/s, 9.2 days; a 55/45 split, 2.1 days; 60/40 locks at once (3.3.1 already says so). Minimum fix: state the bound in spec 3.3.1 and 3.9. Rule option for gate 3: evaluate the floor against the weight table of the last locked checkpoint while no newer lock exists, so a stalled side cannot lift its own share by mining; cost: after a permanent loss of weight the floor needs a manual override instead of the 4.1 days of 3.3.1 D.
|
||||
3. F1 (S5): no certificate should form before the window holds a full window of history (spec 3.8, O-3.1). Proposed: `min_daa = weight_window` (2,592,000 on mainnet, 7,200 on devnet) in `FinalityParams`, one line each.
|
||||
|
||||
Not demonstrated: certificate injection on the wire (S3, S8 half), the eclipse (S7), the 2-hour presence window at mainnet length, the heal rule of 3.5 (both partitions healed without a conflicting certificate, so it was not exercised).
|
||||
|
|
|
|||
240
tools/finality-attacks/README.md
Normal file
240
tools/finality-attacks/README.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Finality v2 attack harness
|
||||
|
||||
Adversarial testing of Igneum sustained-mining finality (rule v2, `docs/spec/03-finality.md`) against a private
|
||||
network of our own `igneumd` nodes. Each scenario has a pass criterion taken from spec section 3 and a measured
|
||||
result. This is robustness and conformance testing of our own software, the practice upstream Kaspa and the
|
||||
Ethereum clients follow. It is gate 3 work (the cryptographer owns gate 3).
|
||||
|
||||
The hostile behaviour lives only in test-only flags of `igneum-miner` (worktree `vendor/igneum-node-fin-attacks`,
|
||||
branch `fin-attacks`), never in honest node or consensus code:
|
||||
|
||||
- `vmine <url> <secs> [--share f] [--bps f] [--label s] [--no-vote] [--equivocate] [--drop-votes] [--sybil b:bb:a:ab] [--pulse burst:on:period]`
|
||||
drives the node over the same `getBlockTemplate` / `submitBlock` / `submitFinalityVote` RPCs a real miner uses.
|
||||
The network runs with `skip_proof_of_work`, so a block is "found" on a Poisson clock at a chosen hash share and
|
||||
the miner controls its share of blocks exactly. Difficulty still retargets from block cadence, so `--pulse`
|
||||
exercises the DAA controller in the loop.
|
||||
- `--equivocate` the miner signs a second, wrong hash at every index (S1).
|
||||
- `--sybil b:bb:a:ab` one miner mints `b` keys with `bb` blocks each (below dust) and `a` keys with `ab`
|
||||
blocks each (above dust), from a single process (S2, ledger F17).
|
||||
- `--drop-votes` the miner strips the node's finality section from its coinbase, so its blocks carry no votes
|
||||
or certificates, while it still votes over RPC (S4, ledger F3).
|
||||
- `--pulse burst:on:period` the submit rate is multiplied by `burst` for `on` seconds of every `period` (S5,
|
||||
ledger F14).
|
||||
- `fin-rpc-attack <url>` submits malformed, mis-signed and replayed votes over `submitFinalityVote` (S8).
|
||||
|
||||
## Isolation (never touch the live devnet)
|
||||
|
||||
Ports 27800 and up, data under `/tmp/igneum-fin-attacks`, network id `igneum-devnet-800` (own handshake magic and
|
||||
data directory). The live devnet (gRPC 26610, p2p 26611, observer 26640/26641/28640) and ports other agents use
|
||||
(up to 27799) are never touched. Loopback addresses are never gossiped, so no link forms that a scenario did not
|
||||
ask for. Everything started is stopped at the end and on SIGINT.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
cd vendor/igneum-node/ && git worktree add -b fin-attacks ../igneum-node-fin-attacks master # once
|
||||
cd ../igneum-node-fin-attacks
|
||||
CARGO_TARGET_DIR=target nice -n 19 ~/.cargo/bin/cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow
|
||||
```
|
||||
|
||||
This produces `target/release/igneumd` and `target/release/igneum-miner`. Override the location with `IGNEUMD`
|
||||
and `IGNEUM_MINER`.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
node tools/finality-attacks/run.mjs # the achievable catalogue, priority order 3,2,1,6,4,8,5
|
||||
node tools/finality-attacks/run.mjs s1 s6 # named scenarios
|
||||
node tools/finality-attacks/run.mjs --quick # short durations (smoke)
|
||||
SCALE=0.7 node tools/finality-attacks/run.mjs # scale every duration
|
||||
```
|
||||
|
||||
Results print as a table and are written to `/tmp/igneum-fin-attacks/results.{txt,json}`; the S8 transcript is at
|
||||
`/tmp/igneum-fin-attacks/s8-fin-rpc-attack.out`. Exit code is non-zero if any scenario failed.
|
||||
|
||||
## The catalogue
|
||||
|
||||
| # | Scenario | Criterion (spec) |
|
||||
|---|---|---|
|
||||
| s3 | dishonest aggregators | other aggregators' certs still lock; a sub-quorum cert cannot lock (Q3); block-carried votes give participation (F3); lock latency < 2 s median |
|
||||
| s2 | Sybil dust | dust keys zero weight; above-dust weight = blocks; total weight = voters' blue blocks; sortition by weight not key count (F17) |
|
||||
| s1 | equivocation at scale | both keys stripped within one checkpoint on every node; no conflicting certificate; honest locks continue (3.6) |
|
||||
| s6 | partition with the floor | 3/3: zero locks either side, resume on heal; 4/2: the 4 side locks, the 2 side does not (Q3 floor, 3.3.1) |
|
||||
| s4 | vote-dropping producer | participation and locks unaffected; delay measured (F3) |
|
||||
| s8 | malformed votes over RPC | rejected without a crash; node stays up (C2) |
|
||||
| s5 | pulsed rental | burst weight proportional to its block share; cannot lock alone (F14) |
|
||||
|
||||
## What this harness does NOT cover (needs a finality-aware p2p probe, not built this session)
|
||||
|
||||
The `submitFinalityVote` RPC is the only way to inject a vote from outside a node; there is no submit-certificate
|
||||
RPC, and certificates only enter a node over p2p message 70 (`protocol/flows/src/v10/finality.rs`) or by a node
|
||||
building one from votes. So three things are out of reach without a p2p probe that speaks the fork's handshake and
|
||||
message 70, which the harness worktree `vendor/igneum-node-harness` has for blocks but not for finality:
|
||||
|
||||
- Injecting a hand-crafted **sub-quorum certificate** to confirm it is rejected on the wire (S3). The in-node
|
||||
path is covered by code review: `FinalityManager::ingest_certificate` verifies the aggregate signature and the
|
||||
voter count, and `lock_test` requires both `3 x signed x P >= 2 x active_num` and `30 x signed >= 17 x total`
|
||||
before a certificate locks, so a certificate below either threshold is recorded as `Certified`, never `Locked`.
|
||||
- The **oversized-bitmap and replayed-certificate** half of S8 over message 70. `Certificate::read` bounds the
|
||||
bitmap at `1 << 20` bytes and the relay flow (`v10/finality.rs`) bounds a message at `1 << 20` bytes and
|
||||
returns `ProtocolError` on a malformed certificate (which disconnects the peer); this is code review, not a
|
||||
live wire test.
|
||||
- A true **vote-dropping relay** (S3/S4). Votes ride in blocks, not only in p2p messages, so a relay that drops
|
||||
message 70 cannot suppress a vote that any block carries (the F3 design). The adversary that can withhold votes
|
||||
is a block producer, which S4 covers with `--drop-votes`.
|
||||
|
||||
Scenario 7 (eclipse of one voter with an adversarial side chain) also needs the p2p probe to feed a private fork
|
||||
and is not run this session.
|
||||
|
||||
## FAIL: S2, aggregator sortition is per key, not per weight (ledger F17)
|
||||
|
||||
`consensus/core/src/finality.rs` `is_aggregator(output, voters, aggregators)` returns
|
||||
`output * voters < aggregators << 64`, where `voters` is the **count** of keys above dust, not their weight. So
|
||||
the 8 aggregators per checkpoint are drawn uniformly over keys, exactly the per-key draw F17 flags for the shard
|
||||
sortition of spec 7.2. Measured: one miner minting 199 above-dust keys made the voter count 199; a real signer's
|
||||
chance of being sortition-eligible fell to about `8 / 199`, so almost no certificate named a legitimate aggregator
|
||||
and the network fell back to "anyone MAY aggregate" (zero-aggregator certificates).
|
||||
|
||||
Blast radius is smaller than the shard case: the aggregator role grants no reward and no power, because any node
|
||||
may aggregate and a certificate must still meet Q3 by **weight**, which a Sybil split does not change (S2 confirms
|
||||
dust keys carry zero weight and the total equals the voters' blue blocks). So this is a liveness and tidiness
|
||||
defect, not a safety one. It should still be fixed so the sortition means what the spec says and S2 never produces
|
||||
8,192-voter certificates with inflated bitmaps (O-3.12).
|
||||
|
||||
Proposed fix (draw the 8 aggregators by weight, mirroring the spec 7.2 step-2 fix for the shard sortition; the
|
||||
rule stays the cryptographer's to ratify, this is a diff for review, not a change made to the rule):
|
||||
|
||||
```diff
|
||||
--- a/consensus/core/src/finality.rs
|
||||
+++ b/consensus/core/src/finality.rs
|
||||
@@ pub fn is_aggregator(output: u64, voters: u64, aggregators: u64) -> bool
|
||||
-/// S1: a voter is an aggregator when its VRF output falls below `aggregators / voters` of the range. With 8 or
|
||||
-/// fewer voters every voter is an aggregator.
|
||||
-pub fn is_aggregator(output: u64, voters: u64, aggregators: u64) -> bool {
|
||||
- if voters <= aggregators {
|
||||
- return true;
|
||||
- }
|
||||
- // output / 2^64 < aggregators / voters <=> output * voters < aggregators * 2^64
|
||||
- (output as u128) * (voters as u128) < (aggregators as u128) << 64
|
||||
-}
|
||||
+/// S1 (F17 fix): a voter is an aggregator when its VRF output falls below `aggregators * weight / total` of the
|
||||
+/// range, so splitting weight across many keys does not buy aggregator tickets (each sub-key's threshold shrinks
|
||||
+/// in proportion). With total weight at or below `aggregators`, every voter is eligible.
|
||||
+pub fn is_aggregator_weighted(output: u64, weight: u64, total_weight: u64, aggregators: u64) -> bool {
|
||||
+ if total_weight <= aggregators || weight == 0 {
|
||||
+ return weight > 0;
|
||||
+ }
|
||||
+ // output / 2^64 < aggregators * weight / total <=> output * total < aggregators * weight * 2^64
|
||||
+ (output as u128) * (total_weight as u128) < (aggregators as u128) * (weight as u128) << 64
|
||||
+}
|
||||
```
|
||||
|
||||
The call site in `consensus/src/processes/finality.rs` (`ingest_vote`, where `is_aggregator` is called with
|
||||
`table.voters.len()`) passes the key's weight and the table total instead of the voter count. A voter with weight
|
||||
`w` then holds an aggregator chance of `aggregators * w / total`, so a split into `n` keys of weight `w/n` holds
|
||||
the same total chance it had as one key. This is the same shape as the spec 7.2 step-2 fix and O-5.1's one-key
|
||||
versus 1,000-keys acceptance test applies unchanged.
|
||||
|
||||
## FAIL: S6A, the floor is time-bounded on a real DAG (spec 3.3.1, 3.7 item 8)
|
||||
|
||||
Measured: a 3/3 split after a 252-s shared warmup (window weight F = 1,439 DAA at the cut). One side crossed the
|
||||
56.7% floor 84 s into a 90-s split and locked 8 checkpoints alone; the other side never did (0 conflicting
|
||||
certificates only because the first side locked indices the second never reached). Both healed cleanly.
|
||||
|
||||
Why: the floor denominator is total weight in the window of C_i, and after the cut only the active side keeps
|
||||
adding blocks to its own window while the other side's blocks are frozen. With share(T) = (F/2 + R T) / (F + R T)
|
||||
(R the side's block rate), the floor is crossed at
|
||||
|
||||
T* = 2F / (13R)
|
||||
|
||||
74 s predicted at F = 1,439 and R = 3 blocks/s, 84 s measured (sibling losses lower R). `sim/finality_v2.py`
|
||||
holds weights fixed during a partition, so it could not see this; spec 3.7 item 8 lists it as outside the model.
|
||||
|
||||
Mainnet scale, approximate (formula only, window slide and DAA lag ignored): full 30-day window F = 2,592,000,
|
||||
1 block/s total:
|
||||
|
||||
| Split | Majority R | T* |
|
||||
|---|---|---|
|
||||
| 50/50 | 0.5 | 9.2 days |
|
||||
| 55/45 | 0.55 | 2.1 days |
|
||||
| 60/40 | 0.6 | locks at once (3.3.1 already says so) |
|
||||
|
||||
Minimum fix: state the bound in spec 3.3.1 and in the exchange guidance of 3.9 (a partition longer than T* can
|
||||
end with one side holding a lock the other never saw).
|
||||
|
||||
Rule option for gate 3 (a diff for review, not applied; it trades liveness for the bound). Evaluate the floor
|
||||
against the weight table of the last locked checkpoint while no newer lock exists, so a stalled side cannot lift
|
||||
its own share of total by mining alone:
|
||||
|
||||
```diff
|
||||
--- a/consensus/src/processes/finality.rs
|
||||
+++ b/consensus/src/processes/finality.rs
|
||||
@@ fn evaluate(&self, state: &mut FinalityState, index: u64)
|
||||
let table = self.voters_at(cp.hash, state);
|
||||
+ // Floor anchor (gate 3 option): while no lock is newer than the last one, the 17/30 test uses the
|
||||
+ // weight table of the last locked checkpoint, so a side that keeps mining during a stall cannot raise
|
||||
+ // its own share of "total". Signers that had no weight at the anchor contribute 0 to the floor.
|
||||
+ let floor_table = match state.locks.iter().next_back() {
|
||||
+ Some((&l, &h)) if l < index => self.voters_at(h, state),
|
||||
+ _ => table.clone(),
|
||||
+ };
|
||||
@@
|
||||
- let passes = self.lock_test(signed, active_num, p, table.total);
|
||||
+ let signed_floor: u64 = signers.iter().map(|k| floor_table.weight(k)).sum();
|
||||
+ let passes = self.lock_test_split(signed, active_num, p, signed_floor, floor_table.total);
|
||||
```
|
||||
|
||||
with `lock_test_split` applying `3 x signed x P >= 2 x active_num` at C_i and `30 x signed_floor >= 17 x
|
||||
floor_total` at the anchor. Cost: after a permanent loss of weight (3.3.1 D, 50% churn) the floor can no longer be
|
||||
met by the survivors or by new miners until an operator moves the anchor, where today it clears in 4.1 days.
|
||||
That is the decision for gate 3; the bound above is the fact either way.
|
||||
|
||||
## FAIL: S5, a burst locks alone while the window is young (ledger F1, spec 3.8 not implemented)
|
||||
|
||||
Measured: a miner with base share 1/6 pulsing 10x for 20 s of every 120 s. Over the run its weight share equalled
|
||||
its block share (35.3% vs 35.3%, ratio 0.999), so W2 counts blocks and the retarget lag bought nothing: the
|
||||
amplification half of F14 passes. But checkpoints 1 to 10 were locked by the burster alone: its first burst gave
|
||||
one key 66.7% to 71.7% of a window that held under 300 blocks (cp 5: 98 of 147 signed by 1 of 6 voters; cp 10:
|
||||
201 of 297), above both Q3 tests. From cp 11 every lock needed 3 or 4 signers as the share decayed.
|
||||
|
||||
This is ledger F1 measured on a real node: with no first-month gate (`FinalityParams::DEVNET.min_daa` 0,
|
||||
`MAINNET.min_daa` 3,600 DAA, one hour against a 30-day window; spec 3.8's rule is "Proposed, Open O-3.1" and 3.10
|
||||
says it is not implemented) a short burst owns a young window and certifies alone.
|
||||
|
||||
Proposed fix (spec 3.8's recommended rule, one line per network):
|
||||
|
||||
```diff
|
||||
--- a/consensus/core/src/finality.rs
|
||||
+++ b/consensus/core/src/finality.rs
|
||||
@@ impl FinalityParams {
|
||||
pub const MAINNET: FinalityParams = FinalityParams {
|
||||
...
|
||||
- min_daa: 3_600,
|
||||
+ // C5 / 3.8: no certificate until the window holds a full window of history (ledger F1)
|
||||
+ min_daa: 2_592_000,
|
||||
};
|
||||
pub const DEVNET: FinalityParams = FinalityParams {
|
||||
...
|
||||
- min_daa: 0,
|
||||
+ min_daa: 7_200,
|
||||
};
|
||||
```
|
||||
|
||||
`evaluate` already gates certificate formation on `cp.daa_score >= self.params.min_daa`, so no other code moves.
|
||||
The litepaper then states the first month runs plain GHOSTDAG under the 12-hour finality depth (F15).
|
||||
|
||||
## Summary of the run (2026-10-04, SCALE 0.6, six voters)
|
||||
|
||||
| # | Scenario | Verdict |
|
||||
|---|---|---|
|
||||
| s3 | dishonest aggregators | PASS (wire injection not run) |
|
||||
| s2 | Sybil dust | weights PASS, sortition FAIL (F17) |
|
||||
| s1 | equivocation at scale | PASS |
|
||||
| s6A | partition 3/3 | FAIL (floor time-bounded, T* = 2F/13R) |
|
||||
| s6B | partition 4/2 | PASS |
|
||||
| s4 | vote-dropping producer | PASS, 0 ms added |
|
||||
| s8 | malformed votes over RPC | PASS (RPC half) |
|
||||
| s5 | pulsed miner | amplification PASS, lock-alone FAIL (F1) |
|
||||
| s7 | eclipse | not run (needs the p2p probe) |
|
||||
|
||||
Full numbers: `docs/bench-log.md`, entry "finality v2 attack harness" of 2026-10-04.
|
||||
164
tools/finality-attacks/lib/net.mjs
Normal file
164
tools/finality-attacks/lib/net.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// Private finality test network of igneumd processes on 127.0.0.1, ports 27800 and up, data under
|
||||
// /tmp/igneum-fin-attacks. Never touches the live devnet (26610/26611, 26640/26641, 28640) or ports other
|
||||
// agents use (up to 27799). The nodes run with skip_proof_of_work: the hostile vmine miners never hash, so a
|
||||
// block is "found" on a Poisson clock at a chosen hash share. Every other consensus rule runs unchanged.
|
||||
//
|
||||
// Topology is explicit. A node with connect:[...] dials only those addresses and accepts no inbound
|
||||
// (--connect sets inbound limit 0); a node without connect listens and dials nothing (--outpeers=0). Loopback
|
||||
// addresses are never gossiped, so no link forms that a scenario did not ask for. A cross-group link runs
|
||||
// through a Proxy that a scenario can cut() and heal().
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, openSync } from 'node:fs';
|
||||
import { createServer, connect as tcpConnect } from 'node:net';
|
||||
import { connectRpc } from './rpc.mjs';
|
||||
|
||||
export const ROOT = new URL('../../../', import.meta.url).pathname; // /Users/joshm/Projects/igneum/
|
||||
export const TARGET = process.env.IGNEUM_FIN_TARGET || `${ROOT}vendor/igneum-node-fin-attacks/target/release`;
|
||||
export const IGNEUMD = process.env.IGNEUMD || `${TARGET}/igneumd`;
|
||||
export const MINER = process.env.IGNEUM_MINER || `${TARGET}/igneum-miner`;
|
||||
export const TMP = '/tmp/igneum-fin-attacks';
|
||||
export const BASE_PORT = 27800; // gRPC/p2p/json for node i at BASE+i*10 (+0/+1/+2)
|
||||
export const DEVNET_SUFFIX = 800; // network id igneum-devnet-800, own handshake magic and data dir
|
||||
|
||||
const started = []; // everything to stop at exit
|
||||
|
||||
export const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
export function overrideParams() {
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
const file = `${TMP}/override.json`;
|
||||
writeFileSync(file, JSON.stringify({ skip_proof_of_work: true }));
|
||||
return file;
|
||||
}
|
||||
|
||||
export class Node {
|
||||
constructor(index, { connect = [], name } = {}) {
|
||||
this.index = index;
|
||||
this.name = name || `n${index}`;
|
||||
this.grpcPort = BASE_PORT + index * 10;
|
||||
this.p2pPort = BASE_PORT + index * 10 + 1;
|
||||
this.jsonPort = BASE_PORT + index * 10 + 2;
|
||||
this.connect = connect;
|
||||
this.dir = `${TMP}/${this.name}`;
|
||||
this.logFile = `${this.dir}/node.log`;
|
||||
this.proc = null; this.rpc = null; this.exited = null;
|
||||
}
|
||||
get p2p() { return `127.0.0.1:${this.p2pPort}`; }
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
get json() { return `ws://127.0.0.1:${this.jsonPort}`; }
|
||||
args() {
|
||||
const a = ['--devnet', `--devnet-suffix=${DEVNET_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=${overrideParams()}`, '--loglevel=info', '--yes'];
|
||||
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
|
||||
return a;
|
||||
}
|
||||
async start() {
|
||||
rmSync(this.dir, { recursive: true, force: true });
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, this.args(), { stdio: ['ignore', out, out] });
|
||||
this.exited = null;
|
||||
this.proc.on('exit', (code, sig) => { this.exited = { code, sig, at: Date.now() }; });
|
||||
started.push(this);
|
||||
await sleep(800);
|
||||
this.rpc = await connectRpc(this.json);
|
||||
log(`${this.name} up pid ${this.proc.pid} json ${this.json} p2p ${this.p2p}`);
|
||||
return this;
|
||||
}
|
||||
alive() { return this.proc && this.exited === null && !this.proc.killed; }
|
||||
logTail(n = 30) { try { return readFileSync(this.logFile, 'utf8').split('\n').slice(-n).join('\n'); } catch { return ''; } }
|
||||
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
async stop() {
|
||||
if (this.rpc) { this.rpc.close(); this.rpc = null; }
|
||||
if (this.proc && this.exited === null) {
|
||||
this.proc.kill('SIGINT');
|
||||
for (let i = 0; i < 100 && this.exited === null; i++) await sleep(100);
|
||||
if (this.exited === null) this.proc.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A hostile vmine miner process (test-only flags in igneum-miner). opts: label, share, bps, secs, vote, equivocate,
|
||||
// dropVotes, sybil ("b:bb:a:ab"), pulse ("burst:on:period").
|
||||
export class Miner {
|
||||
constructor(node, opts = {}) { this.node = node; this.opts = opts; this.proc = null; this.exited = null; this.logFile = `${TMP}/miner-${opts.label || 'm'}.log`; }
|
||||
start() {
|
||||
const o = this.opts;
|
||||
const a = ['vmine', this.node.grpc, String(o.secs ?? 60)];
|
||||
if (o.share != null) a.push('--share', String(o.share));
|
||||
if (o.bps != null) a.push('--bps', String(o.bps));
|
||||
if (o.label) a.push('--label', o.label);
|
||||
if (o.vote === false) a.push('--no-vote');
|
||||
if (o.equivocate) a.push('--equivocate');
|
||||
if (o.dropVotes) a.push('--drop-votes');
|
||||
if (o.sybil) a.push('--sybil', o.sybil);
|
||||
if (o.pulse) a.push('--pulse', o.pulse);
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(MINER, a, { stdio: ['ignore', out, out] });
|
||||
this.exited = null;
|
||||
this.proc.on('exit', (code, sig) => { this.exited = { code, sig }; });
|
||||
started.push(this);
|
||||
return this;
|
||||
}
|
||||
logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } }
|
||||
async stop() {
|
||||
if (this.proc && this.exited === null) {
|
||||
this.proc.kill('SIGINT');
|
||||
for (let i = 0; i < 50 && this.exited === null; i++) await sleep(100);
|
||||
if (this.exited === null) this.proc.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A TCP proxy standing in for one directed p2p link. cut() drops every connection and refuses new ones.
|
||||
export class Proxy {
|
||||
constructor(index, targetPort) {
|
||||
this.port = BASE_PORT + 90 + index; this.targetPort = targetPort; this.openGate = true; this.socks = new Set(); this.server = null;
|
||||
}
|
||||
get addr() { return `127.0.0.1:${this.port}`; }
|
||||
start() {
|
||||
return new Promise((resolve) => {
|
||||
this.server = createServer((client) => {
|
||||
if (!this.openGate) { client.destroy(); return; }
|
||||
const up = tcpConnect(this.targetPort, '127.0.0.1');
|
||||
this.socks.add(client); this.socks.add(up);
|
||||
client.pipe(up); up.pipe(client);
|
||||
const bye = () => { client.destroy(); up.destroy(); this.socks.delete(client); this.socks.delete(up); };
|
||||
client.on('error', bye); up.on('error', bye); client.on('close', bye); up.on('close', bye);
|
||||
});
|
||||
this.server.listen(this.port, '127.0.0.1', () => { started.push(this); resolve(this); });
|
||||
});
|
||||
}
|
||||
cut() { this.openGate = false; for (const s of this.socks) s.destroy(); this.socks.clear(); }
|
||||
heal() { this.openGate = true; }
|
||||
async stop() { this.cut(); await new Promise(r => this.server ? this.server.close(() => r()) : r()); }
|
||||
}
|
||||
|
||||
export async function stopAll() {
|
||||
for (const s of started.splice(0).reverse()) { try { await s.stop(); } catch { } }
|
||||
}
|
||||
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
||||
process.on('SIGTERM', async () => { await stopAll(); process.exit(143); });
|
||||
|
||||
export function assertBinaries() {
|
||||
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) throw new Error(`missing ${b}; build the fin-attacks worktree first`);
|
||||
}
|
||||
|
||||
export async function dagInfo(node) { return node.rpc.call('getBlockDagInfo'); }
|
||||
|
||||
// Poll a node's finality state until predicate(report) or timeout. Returns the last report.
|
||||
export async function pollCheckpoints(node, { timeoutMs = 60000, everyMs = 1000, until } = {}) {
|
||||
const t0 = Date.now();
|
||||
let last = null;
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
try { last = await node.rpc.call('getFinalityCheckpoints', { last: 60 }); } catch { }
|
||||
if (last && until && until(last)) return last;
|
||||
if (!until) return last;
|
||||
await sleep(everyMs);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
54
tools/finality-attacks/lib/rpc.mjs
Normal file
54
tools/finality-attacks/lib/rpc.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// wRPC JSON client for igneumd. Node 22, no dependencies. Method names are the lowerCamelCase of the node's
|
||||
// RpcApiOps (getBlockDagInfo, getFinalityCheckpoints, getFinalityWeights, submitFinalityVote, ...).
|
||||
|
||||
export class Rpc {
|
||||
constructor(url, { timeoutMs = 10_000 } = {}) {
|
||||
this.url = url; this.id = 0; this.pending = new Map(); this.ws = null; this.open = false;
|
||||
this.timeoutMs = timeoutMs; this.onNotification = () => { };
|
||||
}
|
||||
connect() {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(this.url); this.ws = ws;
|
||||
ws.onopen = () => { this.open = true; resolve(true); };
|
||||
ws.onmessage = (e) => {
|
||||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||||
if (m.id !== undefined && m.id !== null && this.pending.has(m.id)) {
|
||||
const p = this.pending.get(m.id); this.pending.delete(m.id);
|
||||
m.error ? p.reject(new Error(typeof m.error === 'string' ? m.error : (m.error.message || JSON.stringify(m.error)))) : p.resolve(m.params);
|
||||
} else if (m.method) this.onNotification(m.method, m.params);
|
||||
};
|
||||
ws.onerror = () => { if (!this.open) resolve(false); };
|
||||
ws.onclose = () => {
|
||||
const wasOpen = this.open; this.open = false;
|
||||
for (const p of this.pending.values()) p.reject(new Error('rpc closed'));
|
||||
this.pending.clear();
|
||||
if (!wasOpen) resolve(false);
|
||||
};
|
||||
setTimeout(() => { if (!this.open) { try { ws.close(); } catch { } resolve(false); } }, 3000);
|
||||
});
|
||||
}
|
||||
close() { try { this.ws && this.ws.close(); } catch { } }
|
||||
call(method, params = {}, timeoutMs = this.timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.open) return reject(new Error('rpc not connected'));
|
||||
const id = ++this.id; this.pending.set(id, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`${method} timed out`)); } }, timeoutMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectRpc(url, { attempts = 60, waitMs = 500 } = {}) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const rpc = new Rpc(url);
|
||||
if (await rpc.connect()) {
|
||||
try { await rpc.call('getInfo'); return rpc; } catch { rpc.close(); }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, waitMs));
|
||||
}
|
||||
throw new Error(`could not reach ${url}`);
|
||||
}
|
||||
|
||||
// Convenience readers (camelCase fields from the node's RPC model).
|
||||
export async function checkpoints(rpc, last = 40) { return rpc.call('getFinalityCheckpoints', { last }); }
|
||||
export async function weights(rpc) { return rpc.call('getFinalityWeights', {}); }
|
||||
349
tools/finality-attacks/run.mjs
Normal file
349
tools/finality-attacks/run.mjs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
// Finality v2 attack runner (docs/spec/03-finality.md, gate 3). Drives a private network of our own igneumd
|
||||
// nodes with the test-only hostile vmine miners and records, per scenario, the spec pass criterion and a
|
||||
// measured result. Priority order 3, 2, 1, 6, 4, 8, 5, 7 (as time allows). Read-only against the spec; the
|
||||
// hostile behaviour lives in igneum-miner test flags, never in honest node or consensus code.
|
||||
//
|
||||
// node tools/finality-attacks/run.mjs # the achievable catalogue
|
||||
// node tools/finality-attacks/run.mjs s1 s6 # named scenarios
|
||||
// node tools/finality-attacks/run.mjs --quick # short durations (smoke)
|
||||
// SCALE=0.5 node tools/finality-attacks/run.mjs # scale every duration
|
||||
|
||||
import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP } from './lib/net.mjs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const QUICK = process.argv.includes('--quick');
|
||||
const SCALE = QUICK ? 0.35 : (parseFloat(process.env.SCALE || '1') || 1);
|
||||
const dur = (s) => Math.max(20, Math.round(s * SCALE));
|
||||
const results = [];
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
|
||||
const sumLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').length;
|
||||
const maxLockedIndex = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').reduce((m, c) => Math.max(m, c.index), 0);
|
||||
function minerLockLatency(miners) {
|
||||
const xs = [];
|
||||
for (const m of miners) {
|
||||
const t = m.logText();
|
||||
const match = t.match(/lock_latency=median (\d+) ms, max (\d+) ms/);
|
||||
if (match) xs.push(+match[1]);
|
||||
}
|
||||
xs.sort((a, b) => a - b);
|
||||
return xs.length ? xs[Math.floor(xs.length / 2)] : null;
|
||||
}
|
||||
function minerFound(miner) {
|
||||
const m = miner.logText().match(/engine=vmine found=(\d+)/);
|
||||
return m ? +m[1] : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Scenario 3: dishonest aggregators. Six voters across three nodes; every node aggregates (anyone MAY). A
|
||||
// certificate below quorum cannot lock (code: FinalityManager::lock_test). We measure that locks still form on
|
||||
// every node, latency stays under 2 s, and votes carried in blocks let participation be computed (F3).
|
||||
async function s3() {
|
||||
const name = 's3-dishonest-aggregators';
|
||||
const secs = dur(300);
|
||||
const n0 = await new Node(0).start();
|
||||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||||
const n2 = await new Node(2, { connect: [n0.p2p] }).start();
|
||||
const miners = [];
|
||||
// two voters per node
|
||||
const plan = [[n0, 'a0'], [n0, 'a1'], [n1, 'b0'], [n1, 'b1'], [n2, 'c0'], [n2, 'c1']];
|
||||
for (const [node, label] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs }).start());
|
||||
await sleep(secs * 1000 + 3000);
|
||||
const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null)));
|
||||
const locked = cps.map(sumLocked);
|
||||
const maxIdx = cps.map(maxLockedIndex);
|
||||
const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
const lat = minerLockLatency(miners);
|
||||
// agreement: every node's set of locked (index->hash) is consistent
|
||||
const hashAt = (cp, idx) => (cp.checkpoints.find(c => c.index === idx && c.state === 'locked') || {}).hash;
|
||||
let agree = true;
|
||||
const common = Math.min(...maxIdx);
|
||||
for (let i = 1; i <= common; i++) { const h = cps.map(cp => hashAt(cp, i)).filter(Boolean); if (new Set(h).size > 1) agree = false; }
|
||||
const pass = locked.every(l => l > 3) && conflicts.every(c => c === 0) && agree && lat != null && lat < 2000;
|
||||
for (const m of miners) await m.stop();
|
||||
results.push({ name, secs,
|
||||
criterion: 'other aggregators’ certs still lock; sub-quorum cert cannot lock (lock_test); block-carried votes give participation (F3); 0 conflicting certs; lock latency < 2 s median',
|
||||
result: `locked per node ${locked.join('/')}, conflicting certs ${conflicts.join('/')}, cross-node lock hashes agree=${agree}, median lock latency ${lat} ms`,
|
||||
pass });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Scenario 2: Sybil dust. One miner mints 200 keys below dust (4 blocks each; dust = 5) and 200 above (6 each).
|
||||
// A few honest voters advance the chain. Criterion: dust keys carry zero weight and are no voters; above-dust
|
||||
// keys carry weight = their blocks; total weight = blue blocks of voters. Aggregator sortition must pick by
|
||||
// weight not key count (F17): the implementation picks per key, reported as a FAIL with its blast radius.
|
||||
async function s2() {
|
||||
const name = 's2-sybil-dust';
|
||||
const secs = dur(300);
|
||||
const n0 = await new Node(0).start();
|
||||
const miners = [];
|
||||
// honest voters keep checkpoints advancing and are the real voter set
|
||||
for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs }).start());
|
||||
// one Sybil miner mints both populations (200 x 4 dust, 200 x 6 above) from a single process
|
||||
const sybil = new Miner(n0, { label: 'syb', secs, sybil: '200:4:200:6', vote: false }).start();
|
||||
// wait for the sybil to finish minting (or the run deadline)
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < secs * 1000) { if (sybil.exited) break; await sleep(2000); }
|
||||
await sleep(3000);
|
||||
const w = await n0.rpc.call('getFinalityWeights', {});
|
||||
const keys = w.keys || [];
|
||||
const dust = keys.filter(k => k.pubkey && k.blocks > 0 && k.blocks < 5);
|
||||
const above = keys.filter(k => k.voter);
|
||||
const dustNonzeroWeightVoters = dust.filter(k => k.voter).length;
|
||||
const totalOfVoters = above.reduce((s, k) => s + k.blocks, 0);
|
||||
const totalMatches = totalOfVoters === w.totalWeight;
|
||||
// F17: aggregator sortition is per key (is_aggregator counts voters, not weight). Inspect a recent checkpoint.
|
||||
const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 5 });
|
||||
const aggCount = (cp.checkpoints.slice(-1)[0] || {}).aggregators?.length ?? 0;
|
||||
const sortitionByKey = (w.voters || 0) > 8; // with >8 voters the per-key draw is observable
|
||||
const weightsOk = dustNonzeroWeightVoters === 0 && totalMatches && above.length > 0;
|
||||
const pass = weightsOk && !sortitionByKey; // FAIL whenever per-key sortition is observable among >8 voters
|
||||
for (const m of miners) await m.stop();
|
||||
await sybil.stop();
|
||||
results.push({ name, secs,
|
||||
criterion: 'dust keys zero weight and no voters; above-dust weight = blocks; total weight = blue blocks of voters; sortition by weight not key count (F17)',
|
||||
result: `dust keys seen ${dust.length} (all non-voters: ${dustNonzeroWeightVoters === 0}); above-dust voters ${above.length}; total weight ${w.totalWeight} = sum of voter blocks ${totalOfVoters} (${totalMatches}); voters=${w.voters}; aggregator sortition is PER KEY (is_aggregator counts voters, not weight), observable with ${w.voters} voters, agg/checkpoint=${aggCount}`,
|
||||
pass });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Scenario 1: equivocation at scale. Six voters across three nodes, two equivocate at every index. Criterion:
|
||||
// both keys stripped within one checkpoint on every node, no conflicting certificate ever forms, honest locks
|
||||
// continue.
|
||||
async function s1() {
|
||||
const name = 's1-equivocation';
|
||||
const secs = dur(300);
|
||||
const n0 = await new Node(0).start();
|
||||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||||
const n2 = await new Node(2, { connect: [n0.p2p] }).start();
|
||||
const miners = [];
|
||||
const plan = [[n0, 'e0', true], [n0, 'e1', true], [n1, 'h0', false], [n1, 'h1', false], [n2, 'h2', false], [n2, 'h3', false]];
|
||||
for (const [node, label, eq] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs, equivocate: eq }).start());
|
||||
await sleep(secs * 1000 + 3000);
|
||||
const ws = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityWeights', {}).catch(() => null)));
|
||||
const strippedPerNode = ws.map(w => (w.keys || []).filter(k => k.strippedUntilDaa > 0).length);
|
||||
const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
const equivDetections = [n0, n1, n2].map(n => n.grepLog(/EQUIVOCATION by key/).length);
|
||||
const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null)));
|
||||
const locked = cps.map(sumLocked);
|
||||
// locks continued after the first strip: the latest locked index is well past the first detection
|
||||
const pass = strippedPerNode.every(s => s >= 2) && conflicts.every(c => c === 0) && locked.every(l => l > 3);
|
||||
for (const m of miners) await m.stop();
|
||||
results.push({ name, secs,
|
||||
criterion: '2 equivocating keys stripped within one checkpoint on every node; no conflicting certificate forms; honest locks continue',
|
||||
result: `stripped keys per node ${strippedPerNode.join('/')} (want >=2 each); equivocation detections ${equivDetections.join('/')}; conflicting certs ${conflicts.join('/')}; locked checkpoints per node ${locked.join('/')}`,
|
||||
pass });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Scenario 6: partition with the floor. Two nodes over a proxy, 6 keys known from a shared warmup. Part A: 3/3
|
||||
// split, neither side holds 56.7% of total, so zero new locks on either side; heal, locks resume. Part B: 4/2
|
||||
// split, the 4 side holds 66.7% and locks, the 2 side does not.
|
||||
async function s6() {
|
||||
const name = 's6-partition-floor';
|
||||
// The floor protects a partition only while the majority side's fresh blocks stay small against its weight
|
||||
// window (devnet window 7,200 DAA). So warm up long enough to fill most of the window, then split briefly. At
|
||||
// ~12 bps the window fills in about 600 s; a 3-miner side adds ~3 bps, so a split under ~300 s keeps the
|
||||
// majority below the 56.7% floor (3.3.1 on a real DAG; spec 3.7 item 8). --quick shrinks both, which exposes
|
||||
// the under-filled-window breach instead (reported honestly as the time-to-breach).
|
||||
const warm = dur(420), split = dur(150), healWin = dur(150);
|
||||
|
||||
async function partition(plan0, plan1, tag) {
|
||||
const secs = warm + split + healWin + 90;
|
||||
const n0 = await new Node(0).start();
|
||||
const proxy = await new Proxy(0, n0.p2pPort).start();
|
||||
const n1 = await new Node(1, { connect: [proxy.addr] }).start();
|
||||
const miners = [];
|
||||
for (const l of plan0) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
for (const l of plan1) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
await sleep(warm * 1000);
|
||||
const w0 = await n0.rpc.call('getFinalityWeights', {}).catch(() => ({}));
|
||||
const before0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 300 }));
|
||||
const before1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 300 }));
|
||||
log(`s6 ${tag} cut at warm ${warm}s: window daa ~${w0.daaScore}, voters ${w0.voters}, max locked ${before0}/${before1}`);
|
||||
proxy.cut();
|
||||
// Poll during the split: record the first new lock beyond the pre-cut index on each side (time to breach)
|
||||
const t0 = Date.now();
|
||||
let breach0 = null, breach1 = null, maxNew0 = before0, maxNew1 = before1;
|
||||
while (Date.now() - t0 < split * 1000) {
|
||||
const c0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null));
|
||||
const c1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null));
|
||||
if (c0 > maxNew0) maxNew0 = c0;
|
||||
if (c1 > maxNew1) maxNew1 = c1;
|
||||
if (breach0 == null && c0 > before0) breach0 = Math.round((Date.now() - t0) / 1000);
|
||||
if (breach1 == null && c1 > before1) breach1 = Math.round((Date.now() - t0) / 1000);
|
||||
await sleep(3000);
|
||||
}
|
||||
const newLocks = (maxNew0 - before0) + (maxNew1 - before1);
|
||||
proxy.heal();
|
||||
await sleep(healWin * 1000);
|
||||
const after0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 800 }));
|
||||
const after1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 800 }));
|
||||
const healed = after0 > maxNew0 && after1 > maxNew1;
|
||||
const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
for (const m of miners) await m.stop();
|
||||
await stopAll();
|
||||
return { before0, before1, maxNew0, maxNew1, newLocks, breach0, breach1, after0, after1, healed, conflicts, daa: w0.daaScore, voters: w0.voters };
|
||||
}
|
||||
|
||||
// Part A: 3/3. Neither side holds 56.7% of the (filled) total, so no side should lock during a short split.
|
||||
const a = await partition(['a0', 'a1', 'a2'], ['b0', 'b1', 'b2'], 'A(3/3)');
|
||||
const passA = a.newLocks === 0 && a.healed && a.conflicts.every(c => c === 0);
|
||||
results.push({ name: name + '-A(3/3)', secs: warm + split + healWin,
|
||||
criterion: '3/3 split on a filled window: zero new locks on either side (neither holds 56.7% of total); locks resume after healing; no conflicting certificates',
|
||||
result: `warmup window daa ~${a.daa} (voters ${a.voters}); new locks during ${split}s split ${a.newLocks} (time to first new lock side0=${a.breach0 ?? 'none'}s side1=${a.breach1 ?? 'none'}s); resumed after heal ${a.healed}; conflicting certs ${a.conflicts.join('/')}`,
|
||||
pass: passA });
|
||||
|
||||
// Part B: 4/2. The 4 side holds 66.7% of total and should keep locking once the 2 silent keys decay out of the
|
||||
// active denominator (presence window); the 2 side (33%) must not lock.
|
||||
const b = await partition(['p0', 'p1', 'p2', 'p3'], ['q0', 'q1'], 'B(4/2)');
|
||||
const passB = b.maxNew0 > b.before0 && b.maxNew1 === b.before1 && b.conflicts.every(c => c === 0);
|
||||
results.push({ name: name + '-B(4/2)', secs: warm + split + healWin,
|
||||
criterion: '4/2 split: the 4 side (66.7% of total) locks (after the 2 silent keys decay from active); the 2 side (33%) does not; no conflicting certificates',
|
||||
result: `4-side locked ${b.before0}->${b.maxNew0} (advanced ${b.maxNew0 > b.before0}, first new lock at ${b.breach0 ?? 'none'}s); 2-side locked ${b.before1}->${b.maxNew1} (stalled ${b.maxNew1 === b.before1}); conflicting certs ${b.conflicts.join('/')}`,
|
||||
pass: passB });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Scenario 4: a block producer that includes no votes in its blocks. One dropper with 40% of blocks on node A;
|
||||
// node B learns votes only through other blocks and p2p. Criterion: participation and locks unaffected; measure
|
||||
// the delay. A control run without dropping gives the baseline latency.
|
||||
async function s4() {
|
||||
const name = 's4-vote-dropping-producer';
|
||||
const secs = dur(300);
|
||||
async function run(drop) {
|
||||
const n0 = await new Node(0).start();
|
||||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||||
const miners = [];
|
||||
// dropper: 40% of blocks on node A; four honest voters share the rest
|
||||
miners.push(new Miner(n0, { label: 'drop', share: 0.40, bps: 6, secs, dropVotes: drop }).start());
|
||||
for (const [node, label, sh] of [[n0, 'h0', 0.15], [n0, 'h1', 0.15], [n1, 'h2', 0.15], [n1, 'h3', 0.15]])
|
||||
miners.push(new Miner(node, { label, share: sh, bps: 6, secs }).start());
|
||||
await sleep(secs * 1000 + 3000);
|
||||
const cpB = await n1.rpc.call('getFinalityCheckpoints', { last: 400 });
|
||||
const lockedB = sumLocked(cpB);
|
||||
const lat = minerLockLatency(miners);
|
||||
for (const m of miners) await m.stop();
|
||||
await stopAll();
|
||||
return { lockedB, lat };
|
||||
}
|
||||
const withDrop = await run(true);
|
||||
const control = await run(false);
|
||||
const delta = (withDrop.lat != null && control.lat != null) ? withDrop.lat - control.lat : null;
|
||||
const pass = withDrop.lockedB > 3 && withDrop.lat != null && withDrop.lat < 2000;
|
||||
results.push({ name, secs,
|
||||
criterion: 'participation and locks unaffected because other blocks carry the votes; delay measured',
|
||||
result: `node B locked checkpoints ${withDrop.lockedB} with a 40% vote-dropping producer; median lock latency ${withDrop.lat} ms vs control ${control.lat} ms (delay ${delta} ms)`,
|
||||
pass });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Scenario 8 (RPC half): malformed, mis-signed and replayed votes over submitFinalityVote. Criterion: each is
|
||||
// rejected without a crash. The p2p message-70 half (certificates, oversized bitmaps) needs a finality-aware
|
||||
// p2p probe, which is not built this session; noted in the README.
|
||||
async function s8() {
|
||||
const name = 's8-malformed-rpc';
|
||||
const secs = dur(90);
|
||||
const n0 = await new Node(0).start();
|
||||
const miners = [];
|
||||
for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs: secs + 60 }).start());
|
||||
await sleep(secs * 1000); // let some checkpoints form so there is a known checkpoint to attack
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const { MINER } = await import('./lib/net.mjs');
|
||||
const out = spawnSync(MINER, ['fin-rpc-attack', n0.grpc], { encoding: 'utf8', timeout: 120000 });
|
||||
const txt = (out.stdout || '') + (out.stderr || '');
|
||||
const m = txt.match(/node_alive_after_each=(\d+)\/(\d+)/);
|
||||
const alive = m ? (+m[1] === +m[2] && +m[2] >= 8) : false;
|
||||
const control = /\[control-valid\] accepted=true/.test(txt);
|
||||
const badSigRejected = /\[bad-signature\] accepted=false/.test(txt);
|
||||
const wrongChainRejected = /\[wrong-chain-id\] accepted=false/.test(txt);
|
||||
const replayDeduped = /\[replay\] accepted=true equivocation=false reason=already known/.test(txt);
|
||||
const garbageRejected = /\[short-garbage\] rpc error/.test(txt) && /\[oversized-2mb\] rpc error/.test(txt) && /\[non-hex\] rpc error/.test(txt);
|
||||
const stillUp = (await n0.rpc.call('getInfo').catch(() => null)) != null;
|
||||
writeFileSync(`${TMP}/s8-fin-rpc-attack.out`, txt);
|
||||
for (const mm of miners) await mm.stop();
|
||||
const pass = alive && control && badSigRejected && wrongChainRejected && replayDeduped && garbageRejected && stillUp;
|
||||
results.push({ name, secs,
|
||||
criterion: 'wrong signatures, wrong index/checkpoint, replays, oversized and non-hex payloads rejected without a crash; node stays up (p2p-70 half needs a probe, not built)',
|
||||
result: `node answered getInfo after every case=${alive}; control accepted=${control}; bad-sig rejected=${badSigRejected}; wrong-chain rejected=${wrongChainRejected}; replay deduped=${replayDeduped}; garbage/oversized/non-hex rejected=${garbageRejected}; node up after=${stillUp}; transcript ${TMP}/s8-fin-rpc-attack.out`,
|
||||
pass });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Scenario 5 (light): pulsed rental against the difficulty controller. A miner bursts to 50x for a short duty
|
||||
// window each period; the DAA controller (Kaspa's rule on master) is in the loop via block cadence. Criterion
|
||||
// (F14): the burst earns weight proportional to its share of blocks over the window; it cannot lock alone.
|
||||
async function s5() {
|
||||
const name = 's5-pulse';
|
||||
const secs = dur(360);
|
||||
const n0 = await new Node(0).start();
|
||||
const miners = [];
|
||||
// Five steady voters plus one burster, all base share 1/6. The burster pulses 10x for 20 s of every 120 s, so
|
||||
// over the window it produces about a third of the blocks and stays below 2/3. F14: its weight share should
|
||||
// equal its block share (no retarget amplification), and a third cannot lock alone (needs 2/3 + the floor).
|
||||
for (const l of ['s0', 's1', 's2', 's3', 's4']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
const burst = new Miner(n0, { label: 'burst', share: 1 / 6, bps: 6, secs, pulse: '10:20:120' }).start();
|
||||
miners.push(burst);
|
||||
await sleep(secs * 1000 + 3000);
|
||||
const w = await n0.rpc.call('getFinalityWeights', {});
|
||||
const keys = (w.keys || []).filter(k => k.pubkey);
|
||||
const burstHash = (burst.logText().match(/key=([0-9a-f]{64})/) || [])[1];
|
||||
const burstKey = burstHash ? keys.find(k => k.keyHash === burstHash) : null;
|
||||
const total = w.totalWeight || keys.reduce((s, k) => s + k.blocks, 0);
|
||||
const burstBlocks = burstKey ? burstKey.blocks : null;
|
||||
const burstWeightShare = burstBlocks != null ? (burstBlocks / total) : null;
|
||||
// Block share from what the burster actually submitted vs all miners
|
||||
const allFound = miners.map(minerFound).filter(x => x != null).reduce((a, b) => a + b, 0);
|
||||
const burstFound = minerFound(burst);
|
||||
const burstBlockShare = (burstFound != null && allFound > 0) ? (burstFound / allFound) : null;
|
||||
const ratio = (burstWeightShare != null && burstBlockShare) ? burstWeightShare / burstBlockShare : null;
|
||||
// Could it lock alone? A lock with only the burster's single vote would show votesSeen == 1. Check no lock did.
|
||||
const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 400 });
|
||||
const soloLocks = (cp.checkpoints || []).filter(c => c.state === 'locked' && c.votesSeen < 2).length;
|
||||
const conflicts = n0.grepLog(/CONFLICTING certificate/).length;
|
||||
for (const m of miners) await m.stop();
|
||||
const pass = ratio != null && ratio > 0.82 && ratio < 1.18 && burstWeightShare < 0.567 && soloLocks === 0 && conflicts === 0;
|
||||
results.push({ name, secs,
|
||||
criterion: 'the burst earns weight proportional to its block share over the window (no retarget amplification, W2/F14) and cannot lock alone',
|
||||
result: burstWeightShare != null
|
||||
? `burster weight share ${(burstWeightShare * 100).toFixed(1)}% vs block share ${(burstBlockShare * 100).toFixed(1)}% (ratio ${ratio.toFixed(3)}, want ~1.0 = no amplification); below the 56.7% floor so cannot lock alone; locks with <2 votes ${soloLocks}; conflicting certs ${conflicts}`
|
||||
: `could not identify the burster key; conflicting certs ${conflicts}`,
|
||||
pass });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const ALL = { s3, s2, s1, s6, s4, s8, s5 };
|
||||
const ORDER = ['s3', 's2', 's1', 's6', 's4', 's8', 's5'];
|
||||
|
||||
async function main() {
|
||||
assertBinaries();
|
||||
const asked = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||||
const run = asked.length ? asked : ORDER;
|
||||
for (const key of run) {
|
||||
const fn = ALL[key];
|
||||
if (!fn) { log(`unknown scenario ${key}`); continue; }
|
||||
log(`=== ${key} starting (scale ${SCALE}) ===`);
|
||||
try { await fn(); } catch (e) { log(`${key} threw: ${e.stack || e}`); results.push({ name: key, criterion: '(scenario errored)', result: String(e.message || e), pass: false }); await stopAll(); }
|
||||
log(`=== ${key} done ===`);
|
||||
}
|
||||
// report
|
||||
const lines = ['', 'SCENARIO RESULTS', '================'];
|
||||
for (const r of results) {
|
||||
lines.push(`\n[${r.pass ? 'PASS' : 'FAIL'}] ${r.name} (${r.secs || '?'} s)`);
|
||||
lines.push(` criterion: ${r.criterion}`);
|
||||
lines.push(` result: ${r.result}`);
|
||||
}
|
||||
const text = lines.join('\n');
|
||||
console.log(text);
|
||||
writeFileSync(`${TMP}/results.txt`, text);
|
||||
writeFileSync(`${TMP}/results.json`, JSON.stringify(results, null, 2));
|
||||
await stopAll();
|
||||
process.exit(results.some(r => !r.pass) ? 1 : 0);
|
||||
}
|
||||
main();
|
||||
Loading…
Reference in a new issue