// VALIDATION: the rotateAndPromote() method + rotated-writer PERSISTENCE across restart.
// (probe-rotation-phantom-check already proved the raw setLocal mechanism co-signs; this
// proves the FIRST-CLASS method and that a restart reuses the persisted rotated writer so
// the peer stays the SAME in-set indexer — no re-promotion needed.)
//
// PASS =
// 1 {A,B}=2 indexers; C a data-bearing plain writer
// 2 C.rotateAndPromote() → C is an indexer; clean 3-set
// 3 rotated-writer keypair PERSISTED + loadable (_loadRotatedKeyPair)
// 4 loaded keypair derives the CURRENT local writer key → restart-restore reuses the SAME
// in-set indexer (the setLocal restore itself runs on a real fresh-process restart; an
// in-process reopen of the same corestore hits the fd-lock the codebase avoids everywhere)
// 5 genesis OFFLINE: rotated C co-signs (B.signedLength advances) — durable
import { StateBaseV3 } from '../lib/state-base-v3.js'
import createTestnet from 'hyperdht/testnet'
import crypto from 'hypercore-crypto'
import Hypercore from 'hypercore'
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-rotmethod-' + 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 }
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 } }
const getK = async (sb, k) => { try { return await sb.get(k) } catch { return 'ERR' } }
let A, B, C
let bootstrap = null; let roomSeed = null
const C_PATH = path.join(ROOT, 'C')
const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })
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
}
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); bootstrap = testnet.bootstrap; roomSeed = crypto.randomBytes(32)
stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA)); await A.ready(); await A.projectMembers(members)
const roomKey = A._roomKey
stage = 'B'; B = new StateBaseV3(path.join(ROOT, 'B'), roomKey, opt(kpB)); await B.ready()
stage = 'C'; C = new StateBaseV3(C_PATH, 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 })
await churn(async () => idx(A) === 2 && idx(B) === 2, 90000)
await A.set('a:world', { n: 1 })
ok(idx(A) === 2 && C._isLocalIndexer() !== true, '1. {A,B}=2 indexers; C not an indexer')
// C plays → data-bearing; 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)
// THE METHOD under test.
stage = 'rotateAndPromote'; const rr = await C.rotateAndPromote({ timeoutMs: 60000 })
console.log(' [diag] rotateAndPromote →', JSON.stringify(rr))
ok(await churn(async () => C._isLocalIndexer() === true && idx(A) === 3 && idx(B) === 3, 60000), '2. rotateAndPromote(): C is indexer; clean 3-set')
// (A) PERSISTENCE write + load round-trip. The actual restart-RESTORE (setLocal to the loaded
// key on ready()) runs on a real FRESH-PROCESS daemon restart — an in-process reopen of the
// same corestore hits the fd-lock the codebase avoids everywhere (even _quarantineAndReset
// opens a fresh dir, never the locked one). Here we prove the persisted keypair is present and
// faithfully derives the current in-set indexer's writer key, so restore reuses THAT indexer.
stage = 'persist'
const loaded = C._loadRotatedKeyPair()
ok(!!(loaded && loaded.publicKey && loaded.secretKey), '3. rotated-writer keypair PERSISTED + loadable')
// Derive the core key PURELY (Hypercore.key(manifest)) — same computation setLocal uses
// (index.js:492-495). Do NOT open a store session on this key: it's C's LIVE local writer,
// and closing a duplicate session would tear C's writer down (that broke the durability step).
const expectedKey = loaded ? Hypercore.key({ version: C.store.manifestVersion, signers: [{ publicKey: loaded.publicKey }] }) : null
ok(!!expectedKey && Buffer.compare(expectedKey, C.base.local.key) === 0, '4. loaded keypair derives the CURRENT local writer key → restore reuses the SAME in-set indexer')
// Let the fresh 3-indexer set STABILIZE (C becomes an ACTIVE signer) before dropping the
// genesis — a freshly-promoted indexer needs a window to start acking; in prod you rotate
// proactively, not at the instant of losing the creator. (phantom-check does the same.)
stage = 'stabilize'; await churn(null, 15000)
// Durability re-confirm (no restart): with the genesis OFFLINE, the rotated C co-signs.
stage = 'closeA'; try { A._closing = true; await A.close() } catch {}
const sB = slen(B)
await churn(null, 20000)
console.log(' [diag] genesis offline: B.signedLength %d → %d (Δ%d) | idx B=%s C=%s', sB, slen(B), slen(B) - sB, idx(B), idx(C))
ok(slen(B) - sB > 0, '5. GENESIS OFFLINE: rotated C co-signs (B.signedLength advances) — durable')
clearTimeout(watchdog)
console.log('\n RESULT: ' + pass + ' passed, ' + fail + ' failed')
Bare.exit(fail === 0 ? 0 : 1)
}
main().catch((e) => { console.log(' FATAL ' + (e && e.stack || e)); Bare.exit(2) })