// DIAGNOSTIC: is a setLocal-rotated indexer a REAL signer or a PHANTOM?
// Prior run: after rotating C into the indexer set, B.signedLength went FLAT (141→141)
// even under constant churn — suggesting finalization stalls post-rotation. This isolates
// WHERE by measuring signedLength deltas over fixed churn windows at three stages:
//
// STAGE A (control, pre-rotation, {A,B}=2 both online):
// signedLength MUST advance — proves the metric works in this harness at all.
// STAGE B (post-rotation, {A,B,C}=3 ALL online):
// if signedLength advances → rotation didn't break signing (A+B alone are a quorum).
// if FLAT → the 3-indexer migration itself stalled finalization (rotation is broken).
// STAGE C (post-rotation, GENESIS A offline → quorum needs B+C):
// if signedLength advances → C actually SIGNS (REAL indexer) → rotation delivers durability.
// if FLAT → C is a PHANTOM (counted in the set, reads fine, but doesn't co-sign) → stalls.
//
// This does NOT pass/fail on a guessed metric — it PRINTS the deltas so we read the truth.
import { StateBaseV3 } from '../lib/state-base-v3.js'
import createTestnet from 'hyperdht/testnet'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
const ROOT = path.join(os.tmpdir(), 'sbv3-phantom-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')
let kpA = crypto.keyPair(); let kpB = crypto.keyPair()
if (hex(kpB.publicKey) < hex(kpA.publicKey)) { const t = kpA; kpA = kpB; kpB = t } // kpA = lex-lowest role-1 = genesis
const kpC = crypto.keyPair()
const members = [
{ key: hex(kpA.publicKey), role: 1 },
{ key: hex(kpB.publicKey), role: 1 },
{ key: hex(kpC.publicKey), role: 3 }
]
let pass = 0; let fail = 0
const ok = (c, m) => { if (c) { pass++; console.log(' ok ' + m) } else { fail++; console.log(' FAIL ' + m) } }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const idx = (sb) => { try { return sb.base.linearizer.indexers.length } catch { return '?' } }
const slen = (sb) => { try { return sb.base.signedLength } catch { return -1 } }
let A, B, C
const live = () => [A, B, C].filter(x => x && x.base && !x._closing)
let hb = 0
const churn = async (predicate, maxMs, writeC = true) => {
const deadline = Date.now() + maxMs
while (Date.now() < deadline) {
for (const x of live()) { if (x.base.writable && (x !== C || writeC)) { try { await x.set('__hb__:' + hex(x._selfKey).slice(0, 6), hb++) } catch {} } }
for (const x of live()) await x.base.update().catch(() => {})
await sleep(150)
if (predicate && await predicate()) return true
}
return predicate ? await predicate() : true
}
// churn for a FIXED window, return B.signedLength delta.
const measure = async (label, ms) => {
const before = slen(B)
await churn(null, ms)
const after = slen(B)
console.log(' [measure] %s: B.signedLength %d → %d (Δ%d) | idx A=%s B=%s C=%s | slen A=%s C=%s',
label, before, after, after - before, idx(A), idx(B), idx(C), slen(A), slen(C))
return after - before
}
let stage = 'init'
const watchdog = setTimeout(() => { console.log(' [WATCHDOG] stuck at stage=' + stage + ' — exiting'); Bare.exit(3) }, 260000)
async function main () {
stage = 'testnet'
const testnet = await createTestnet(3)
const bootstrap = testnet.bootstrap
const roomSeed = crypto.randomBytes(32)
const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })
stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA)); await A.ready(); await A.projectMembers(members)
stage = 'B'; B = new StateBaseV3(path.join(ROOT, 'B'), A._roomKey, opt(kpB)); await B.ready()
stage = 'C'; C = new StateBaseV3(path.join(ROOT, 'C'), A._roomKey, opt(kpC)); await C.ready()
await churn(async () => (B.base.length || 0) > 0 && (C.base.length || 0) > 0, 45000)
stage = 'growB'; await B.requestWriter({ timeoutMs: 60000, startIndexing: true })
ok(await churn(async () => idx(A) === 2 && idx(B) === 2, 90000), '1. {A,B}=2 indexers')
// STAGE A — control: does signedLength advance pre-rotation?
stage = 'measA'; const dA = await measure('STAGE-A pre-rotation {A,B} online', 12000)
ok(dA > 0, '2. CONTROL: signedLength advances pre-rotation (metric works)')
// C plays → data-bearing writer, granted admin.
stage = 'play'; await C.set('c:score', { pts: 100 }); await churn(async () => (await C.effectiveWritable()) === true, 40000)
members[2].role = 1; await A.projectMembers(members); await churn(async () => (await C.getProjectedRole(hex(kpC.publicKey))) === 1, 30000)
await churn(null, 4000, false) // quiesce C
// ROTATE via setLocal.
stage = 'rotate'
const freshKp = crypto.keyPair(); const freshCore = C.store.get({ keyPair: freshKp }); await freshCore.ready()
await C.base.setLocal(freshCore.key); await C.base.update()
await C.requestWriter({ timeoutMs: 60000, startIndexing: true })
ok(await churn(async () => C._isLocalIndexer() === true && idx(A) === 3 && idx(B) === 3 && idx(C) === 3, 60000), '3. rotated C is indexer; set=3 on all')
// STAGE B — post-rotation, ALL online. A+B alone are a quorum, so this MUST advance
// unless the 3-indexer migration itself stalled signing.
stage = 'measB'; const dB = await measure('STAGE-B post-rotation, ALL online', 15000)
ok(dB > 0, '4. post-rotation signedLength advances with all 3 online (migration did not stall signing)')
// STAGE C — genesis A offline. Now finalization NEEDS B+C. Advance ⇒ C signs (REAL);
// flat ⇒ C is a PHANTOM (only B, below majority).
stage = 'closeA'; try { A._closing = true; await A.close() } catch {}
const dC = await measure('STAGE-C genesis OFFLINE (needs B+C)', 20000)
console.log('\n VERDICT: rotated C is %s', dC > 0 ? 'a REAL signer (B+C quorum finalized with genesis offline)' : 'a PHANTOM (finalization STALLED with genesis offline — C does not co-sign)')
ok(dC > 0, '5. PHANTOM CHECK: signedLength advances with genesis offline ⇒ rotated C really co-signs')
clearTimeout(watchdog)
console.log('\n RESULT: ' + pass + ' passed, ' + fail + ' failed (deltas A=' + dA + ' B=' + dB + ' C=' + dC + ')')
Bare.exit(fail === 0 ? 0 : 1)
}
main().catch((e) => { console.log(' FATAL ' + (e && e.stack || e)); Bare.exit(2) })