X14: the signing half. No RPC exposes a certificate's signer set, so
tools/finality-attacks/x14-concentration.mjs now walks the selected
chain over the window, decodes the coinbase finality section (IGNF
trailer: votes, certificates, evidence, the IGNK reveal), rebuilds the
canonical voter list at every checkpoint from headers the way the node's
compute_weights does, and maps every bitmap through it. Read-only on the
Mac observer node under the run lock, node version and DAA recorded,
two readings kept (the window straddles the 0.3.10 restart). Result at
23:00:44 UTC, DAA 138,542: signed weight over the heaviest certificate
per index, 27 keys, top-1 10.0%, top-3 29.1%, top-10 77.3%; hashing
6.4/19.2/60.1, aggregation 44.9/84.1/100, proving 100/100/100. Checks:
240 of 240 rebuilt voter lists equal the node's count, 194 of 194
certificates mapped, 27 reveals against BLAKE2b with 0 mismatches.
Status moved to Answered with evidence for all four; the old status
kept after "Was:". Bench-log entry appended.
X5 (paragraph only; Status stays Decision owner: the project lead): the observer
columns of O-X.1 on this branch, not deployed, the running observer
untouched: live_peer_asn (offline prefix table, no third-party lookup),
live_key_machines (machine fingerprint per vote key from the log
intake), live_pool_statements (signed JSON {pool, keys[], signed_at},
Ed25519, parser and verifier), live_concentration (nightly top-1/3/10
for the four concentrations plus N_ind labelled "proposed definition").
Pure functions in tools/observer/lib/concentration.mjs and
lib/nightly.mjs; keyed BLAKE2b in tools/finality-attacks/lib/blake2b.mjs;
9 node:test tests, all passing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
186 lines
14 KiB
JavaScript
186 lines
14 KiB
JavaScript
// node --test tools/observer/test/ (Node 22, no dependencies)
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { generateKeyPairSync, sign as edSign } from 'node:crypto';
|
|
import { decodeFinalitySection, coinbaseExtra, voterTableAt, topShares, signingShares, independenceClasses,
|
|
parsePoolStatement, verifyPoolStatement, poolStatementBytes, parseAsnTable, asnOf } from '../lib/concentration.mjs';
|
|
import { nightlyRow, signedWeightFromStored } from '../lib/nightly.mjs';
|
|
import { blake2b, voteKeyHash, selfTest } from '../../finality-attacks/lib/blake2b.mjs';
|
|
|
|
// ---------- payload fixtures (the fork's Vote::write, Certificate::write, encode_section) ----------
|
|
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
|
|
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n); return b; };
|
|
const fill = (n, v) => Buffer.alloc(n, v);
|
|
const vote = (index, cp, pub) => Buffer.concat([Buffer.from([1]), u64(index), cp, pub, fill(96, 0xaa), fill(96, 0xbb)]);
|
|
const cert = (index, cp, voterCount, bitmap, aggregator) => Buffer.concat([Buffer.from([2]), u64(index), cp, u32(voterCount), u32(bitmap.length), bitmap, fill(96, 0xcc), aggregator, fill(96, 0xdd)]);
|
|
const section = (items) => { const body = Buffer.concat(items); return Buffer.concat([body, u32(body.length), Buffer.from('IGNF')]); };
|
|
const coinbase = (extra) => Buffer.concat([u64(1), u64(2), Buffer.from([0, 0]), Buffer.from([3]), Buffer.from([1, 2, 3]), extra]);
|
|
const PUB = Buffer.from('b95b6d4f2f7e9887727aab42e68d25dd76e2d65c0135366c6de1a813f20eefd996cf7fc2e58bf90919cd2ea399cbd6c4', 'hex');
|
|
const CP = fill(32, 0x11);
|
|
|
|
test('blake2b: RFC vectors and the live vote key hash', () => {
|
|
assert.deepEqual(selfTest(), { ok: true });
|
|
// read from the Mac observer node on 5 October 2026 (getFinalityWeights.keys[0]): pubkey -> keyHash
|
|
assert.equal(voteKeyHash(PUB.toString('hex')), 'a72f7b7c4725dfc18df87752598932a0ed40bbef839691b06a4c88c5527315c5');
|
|
assert.equal(blake2b(Buffer.from('abc'), { outlen: 32 }).length, 32);
|
|
});
|
|
|
|
test('decodeFinalitySection reads votes, certificates, evidence and the reveal from a coinbase payload', () => {
|
|
const reveal = Buffer.from('2.1.0/IGNK' + PUB.toString('hex') + 'ef'.repeat(96));
|
|
const bm = Buffer.from([0b00001011]); // positions 0, 1, 3
|
|
const agg = fill(32, 0x22);
|
|
const ev = Buffer.concat([Buffer.from([3]), vote(7, CP, PUB).subarray(1), vote(7, fill(32, 0x33), PUB).subarray(1)]);
|
|
const extra = Buffer.concat([reveal, section([vote(5, CP, PUB), cert(5, CP, 4, bm, agg), ev])]);
|
|
const d = decodeFinalitySection(coinbase(extra));
|
|
assert.equal(d.votes.length, 1); assert.equal(d.votes[0].index, 5); assert.equal(d.votes[0].pubkey, PUB.toString('hex')); assert.equal(d.votes[0].checkpoint, CP.toString('hex'));
|
|
assert.equal(d.certificates.length, 1);
|
|
const c = d.certificates[0];
|
|
assert.equal(c.index, 5); assert.equal(c.voterCount, 4); assert.deepEqual(c.signerPositions, [0, 1, 3]); assert.equal(c.aggregator, agg.toString('hex'));
|
|
assert.equal(d.evidence.length, 1); assert.equal(d.evidence[0].first.index, 7); assert.notEqual(d.evidence[0].first.checkpoint, d.evidence[0].second.checkpoint);
|
|
assert.equal(d.reveal.pubkey, PUB.toString('hex'));
|
|
assert.equal(d.malformed, false);
|
|
// raw extra data decodes the same; a hex string too
|
|
assert.equal(decodeFinalitySection(extra, { raw: true }).certificates.length, 1);
|
|
assert.equal(decodeFinalitySection(coinbase(extra).toString('hex')).votes.length, 1);
|
|
});
|
|
|
|
test('decodeFinalitySection: a bitmap bit beyond voter_count is ignored, a payload without a section is empty, a truncated section yields what decoded', () => {
|
|
const bm = Buffer.from([0b11111111]);
|
|
const c = decodeFinalitySection(coinbase(section([cert(1, CP, 3, bm, fill(32, 0))]))).certificates[0];
|
|
assert.deepEqual(c.signerPositions, [0, 1, 2]);
|
|
assert.deepEqual(decodeFinalitySection(coinbase(Buffer.from('2.1.0/'))), { votes: [], certificates: [], evidence: [], reveal: null, sectionBytes: 0, malformed: false });
|
|
const good = vote(1, CP, PUB), bad = Buffer.concat([Buffer.from([2]), u64(1), CP, u32(9), u32(5000)]);
|
|
const body = Buffer.concat([good, bad]);
|
|
const d = decodeFinalitySection(Buffer.concat([body, u32(body.length), Buffer.from('IGNF')]), { raw: true });
|
|
assert.equal(d.votes.length, 1); assert.equal(d.malformed, true);
|
|
assert.equal(coinbaseExtra(Buffer.alloc(5)).length, 0);
|
|
});
|
|
|
|
test('voterTableAt rebuilds the node rule: window, merger, dust, sort order, bans', () => {
|
|
const K = (c) => c.repeat(64);
|
|
const blues = [
|
|
// daa, key, mergerDaa
|
|
{ hash: 'c', daaScore: 100, voteKeyHash: K('b'), mergerDaa: 101 }, // the checkpoint itself (merged by its child)
|
|
{ hash: 'x1', daaScore: 99, voteKeyHash: K('b'), mergerDaa: 100 },
|
|
{ hash: 'x2', daaScore: 98, voteKeyHash: K('a'), mergerDaa: 100 },
|
|
{ hash: 'x3', daaScore: 97, voteKeyHash: K('a'), mergerDaa: 100 },
|
|
{ hash: 'x4', daaScore: 95, voteKeyHash: K('a'), mergerDaa: 103 }, // merged after C: not in C's past
|
|
{ hash: 'x5', daaScore: 90, voteKeyHash: K('a'), mergerDaa: 100 }, // window (90, 100]: 90 is out
|
|
{ hash: 'x6', daaScore: 96, voteKeyHash: K('d'), mergerDaa: 100 }, // one block: dust
|
|
];
|
|
const t = voterTableAt({ hash: 'c', daaScore: 100 }, blues, { weightWindow: 10, dust: 2 });
|
|
assert.equal(t.perKey.get(K('a')), 2); assert.equal(t.perKey.get(K('b')), 2); assert.equal(t.perKey.get(K('d')), 1);
|
|
assert.deepEqual(t.voters, [K('a'), K('b')]); assert.equal(t.total, 4); assert.equal(t.keys, 3);
|
|
const banned = voterTableAt({ hash: 'c', daaScore: 100 }, blues, { weightWindow: 10, dust: 2 }, new Map([[K('a'), 200]]));
|
|
assert.deepEqual(banned.voters, [K('b')]);
|
|
const expired = voterTableAt({ hash: 'c', daaScore: 100 }, blues, { weightWindow: 10, dust: 2 }, new Map([[K('a'), 100]]));
|
|
assert.deepEqual(expired.voters, [K('a'), K('b')]);
|
|
});
|
|
|
|
test('topShares and signingShares', () => {
|
|
const s = topShares(new Map([['a', 50], ['b', 30], ['c', 20]]));
|
|
assert.equal(s.keys, 3); assert.equal(s.total, 100); assert.equal(s.top1, 0.5); assert.equal(s.top3, 1); assert.equal(s.top10, 1);
|
|
assert.deepEqual(topShares(new Map()), { keys: 0, total: 0, top1: 0, top3: 0, top10: 0 });
|
|
const tables = new Map([[5, { hash: 'h5', voters: ['k1', 'k2', 'k3'], perKey: new Map([['k1', 10], ['k2', 20], ['k3', 30]]) }]]);
|
|
const r = signingShares([
|
|
{ index: 5, checkpoint: 'h5', voterCount: 3, signerPositions: [0, 2] },
|
|
{ index: 5, checkpoint: 'h5', voterCount: 3, signerPositions: [2] },
|
|
{ index: 5, checkpoint: 'other', voterCount: 3, signerPositions: [0] },
|
|
{ index: 5, checkpoint: 'h5', voterCount: 4, signerPositions: [0] },
|
|
{ index: 6, checkpoint: 'h6', voterCount: 3, signerPositions: [0] },
|
|
], tables);
|
|
assert.equal(r.mapped, 2); assert.equal(r.unmapped.length, 3);
|
|
assert.equal(r.weight.get('k1'), 10); assert.equal(r.weight.get('k3'), 60); assert.equal(r.count.get('k3'), 2);
|
|
assert.match(r.unmapped[0].reason, /another block/); assert.match(r.unmapped[1].reason, /voter_count 4/); assert.match(r.unmapped[2].reason, /no table/);
|
|
});
|
|
|
|
test('independenceClasses: the X5 reading (21 keys on 5 machines) and the silent-fleet rule', () => {
|
|
const keys = []; const attrs = new Map();
|
|
for (let i = 0; i < 21; i++) { const k = `k${i}`; keys.push({ keyHash: k, blocks: 100 }); attrs.set(k, { asn: 'AS1', fingerprint: `m${i % 5}`, pool: null }); }
|
|
keys.push({ keyHash: 'dust', blocks: 1 });
|
|
const r = independenceClasses(keys, attrs, 5);
|
|
assert.equal(r.nInd, 5); assert.equal(r.eligible, 21); assert.equal(r.unattributed, 0);
|
|
// the same machines behind a pool statement are still 5 classes; a second pool on the same machines is 10
|
|
for (const [k, a] of attrs) a.pool = 'p1';
|
|
assert.equal(independenceClasses(keys, attrs, 5).nInd, 5);
|
|
attrs.get('k0').pool = 'p2';
|
|
assert.equal(independenceClasses(keys, attrs, 5).nInd, 6);
|
|
// silent keys (no fingerprint): own class only when the ASN is theirs alone
|
|
const silent = [{ keyHash: 's1', blocks: 10 }, { keyHash: 's2', blocks: 10 }, { keyHash: 's3', blocks: 10 }];
|
|
const sa = new Map([['s1', { asn: 'AS9', fingerprint: null, pool: null }], ['s2', { asn: 'AS9', fingerprint: null, pool: null }], ['s3', { asn: 'AS10', fingerprint: null, pool: null }]]);
|
|
const rs = independenceClasses(silent, sa, 5);
|
|
assert.equal(rs.nInd, 2);
|
|
// a key with nothing known counts as its own class and is reported as unattributed
|
|
const none = independenceClasses([{ keyHash: 'n1', blocks: 10 }, { keyHash: 'n2', blocks: 10 }], new Map(), 5);
|
|
assert.equal(none.nInd, 2); assert.equal(none.unattributed, 2);
|
|
});
|
|
|
|
test('pool statement: sign, parse, verify, and every refusal', () => {
|
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
const pubHex = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
|
|
const keys = ['B'.repeat(64), 'a'.repeat(64)];
|
|
const signed_at = new Date(Date.now() - 3600e3).toISOString();
|
|
const body = { pool: 'ember-pool', keys, signed_at };
|
|
const sig = edSign(null, poolStatementBytes(body), privateKey).toString('hex');
|
|
const text = JSON.stringify({ format: 'igneum-pool-statement-1', ...body, pubkey: pubHex, sig });
|
|
const p = parsePoolStatement(text);
|
|
assert.equal(p.ok, true, p.error);
|
|
assert.deepEqual(p.statement.keys, ['a'.repeat(64), 'b'.repeat(64)]);
|
|
const registry = { 'ember-pool': pubHex };
|
|
assert.deepEqual(verifyPoolStatement(p.statement, registry), { ok: true });
|
|
assert.deepEqual(verifyPoolStatement(p.statement, new Map([['ember-pool', pubHex]])), { ok: true });
|
|
// tampered keys
|
|
const t = { ...p.statement, keys: [...p.statement.keys, 'c'.repeat(64)] };
|
|
assert.match(verifyPoolStatement(t, registry).error, /does not verify/);
|
|
// unknown pool, wrong registered key, stale, future
|
|
assert.match(verifyPoolStatement({ ...p.statement, pool: 'other' }, registry).error, /no registered key/);
|
|
assert.match(verifyPoolStatement(p.statement, { 'ember-pool': 'ff'.repeat(32) }).error, /the pool's key/);
|
|
assert.match(verifyPoolStatement(p.statement, registry, { now: Date.now() + 40 * 86400e3 }).error, /over the 35-day limit/);
|
|
assert.match(verifyPoolStatement({ ...p.statement, signed_at: new Date(Date.now() + 3600e3).toISOString() }, registry).error, /future/);
|
|
// shape refusals
|
|
assert.match(parsePoolStatement('nope').error, /not JSON/);
|
|
assert.match(parsePoolStatement({ format: 'x' }).error, /format/);
|
|
assert.match(parsePoolStatement({ format: 'igneum-pool-statement-1', pool: 'Bad Pool' }).error, /pool must be/);
|
|
assert.match(parsePoolStatement({ format: 'igneum-pool-statement-1', pool: 'p', keys: [] }).error, /non-empty/);
|
|
assert.match(parsePoolStatement({ format: 'igneum-pool-statement-1', pool: 'p', keys: ['zz'] }).error, /not a 64-hex/);
|
|
assert.match(parsePoolStatement({ format: 'igneum-pool-statement-1', pool: 'p', keys: ['a'.repeat(64), 'A'.repeat(64)] }).error, /repeat/);
|
|
assert.match(parsePoolStatement({ format: 'igneum-pool-statement-1', pool: 'p', keys: ['a'.repeat(64)], signed_at: 'yesterday' }).error, /ISO 8601/);
|
|
assert.match(parsePoolStatement({ format: 'igneum-pool-statement-1', pool: 'p', keys: ['a'.repeat(64)], signed_at, pubkey: 'x' }).error, /pubkey/);
|
|
assert.match(parsePoolStatement({ format: 'igneum-pool-statement-1', pool: 'p', keys: ['a'.repeat(64)], signed_at, pubkey: pubHex, sig: 'x' }).error, /sig/);
|
|
});
|
|
|
|
test('asn table: longest prefix, private ranges, misses, IPv6', () => {
|
|
const table = parseAsnTable('# comment\n10.0.0.0/8 AS-IGNORED private anyway\n203.0.113.0/24 AS64500 Example Net\n203.0.0.0/16 AS64501 Wider\nbad line\n2001:db8::/32 AS1 v6-not-read\n');
|
|
assert.equal(table.length, 3);
|
|
assert.deepEqual(asnOf('203.0.113.9:26611', table), { asn: 'AS64500', name: 'Example Net' });
|
|
assert.deepEqual(asnOf('203.0.7.1', table), { asn: 'AS64501', name: 'Wider' });
|
|
assert.equal(asnOf('198.51.100.1:1', table), null);
|
|
assert.deepEqual(asnOf('192.168.68.67:26611', table), { asn: 'local', name: 'private or loopback' });
|
|
assert.deepEqual(asnOf('127.0.0.1:26611', table), { asn: 'local', name: 'private or loopback' });
|
|
assert.equal(asnOf('[2001:db8::1]:26611', table), null);
|
|
assert.equal(asnOf('', table), null);
|
|
assert.equal(parseAsnTable('').length, 0);
|
|
});
|
|
|
|
test('nightly row: the four shares and N_ind labelled proposed', () => {
|
|
const pk = PUB.toString('hex');
|
|
const weights = { params: { dust: 5 }, checkpointIndex: 4500, daaScore: 138000, keys: [
|
|
{ keyHash: 'a72f7b7c4725dfc18df87752598932a0ed40bbef839691b06a4c88c5527315c5', blocks: 60, voter: true },
|
|
{ keyHash: 'k2', blocks: 40, voter: true }, { keyHash: 'k3', blocks: 2, voter: false }] };
|
|
const checkpoints = [{ state: 'locked', certificateAggregator: 'agg1' }, { state: 'locked', certificateAggregator: '0'.repeat(64) }, { state: 'proposed', certificateAggregator: 'agg2' }];
|
|
// stored as live_certificates stores them: voters with pubkey and weight, in canonical order; bitmap bits 0 and 1
|
|
const certificates = [
|
|
{ index: 1, bitmap_hex: '03', voter_count: 2, voters: [{ pubkey: pk, weight: 60 }, { keyHash: 'k2', weight: 40 }] },
|
|
{ index: 2, bitmap_hex: '01', voter_count: 2, voters: [{ pubkey: pk, weight: 60 }, { keyHash: 'k2', weight: 40 }] },
|
|
{ index: 3, bitmap_hex: '01', voter_count: 3, voters: [{ pubkey: pk, weight: 60 }] }, // table length disagrees: skipped
|
|
];
|
|
const sw = signedWeightFromStored(certificates);
|
|
assert.equal(sw.used, 2); assert.equal(sw.skipped, 1);
|
|
assert.equal(sw.weight.get('a72f7b7c4725dfc18df87752598932a0ed40bbef839691b06a4c88c5527315c5'), 120); assert.equal(sw.weight.get('k2'), 40);
|
|
const row = nightlyRow({ weights, checkpoints, certificates, proofs: new Map([['p1', 3], ['p2', 1]]), attributes: new Map([['k2', { asn: null, fingerprint: 'm1', pool: null }]]), meta: { nodeVersion: '2.1.0', day: '2026-10-06' } });
|
|
assert.equal(row.day, '2026-10-06'); assert.equal(row.daa, 138000); assert.equal(row.node_version, '2.1.0');
|
|
assert.equal(row.hashing.top1, 60 / 102); assert.equal(row.hashing.above_dust, 2);
|
|
assert.equal(row.signing.top1, 120 / 160); assert.equal(row.signing.certificates, 2); assert.equal(row.signing.skipped, 1);
|
|
assert.equal(row.proving.top1, 0.75); assert.equal(row.aggregation.keys, 1); assert.equal(row.aggregation.anonymous, 1);
|
|
assert.equal(row.n_ind, 2); assert.equal(row.n_ind_definition, 'proposed'); assert.equal(row.n_ind_eligible, 2); assert.equal(row.n_ind_unattributed, 1);
|
|
});
|