// src/v3/bin/v3-devnet.ts
import { createServer as createServer2 } from "http";
import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, access } from "fs/promises";
import { dirname as dirname3 } from "path";
import { randomBytes } from "crypto";
import { generateKeypair, sign, addressBytesFromPublicKey, bech32AddressFromPublicKey } from "@asentum/crypto";

// src/v3/boot.ts
import { readFile as readFile3 } from "fs/promises";
import { join } from "path";

// src/v3/genesis-spec.ts
import { blake3 } from "@asentum/crypto";
function hexToBytes(hex) {
  const clean = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex;
  const bytes = new Uint8Array(clean.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
  }
  return bytes;
}
function validateGenesisSpec(spec) {
  if (!spec || typeof spec !== "object") {
    throw new Error("v3-genesis: spec is not an object");
  }
  const s = spec;
  if (s.version !== 3) {
    throw new Error(`v3-genesis: version must be 3, got ${String(s.version)}`);
  }
  if (typeof s.chainId !== "number" || !Number.isInteger(s.chainId) || s.chainId < 1) {
    throw new Error("v3-genesis: chainId must be a positive integer");
  }
  if (typeof s.chainName !== "string" || s.chainName.length === 0) {
    throw new Error("v3-genesis: chainName must be a non-empty string");
  }
  if (typeof s.slot0StartUnixMs !== "number" || s.slot0StartUnixMs < 0) {
    throw new Error("v3-genesis: slot0StartUnixMs must be a non-negative number");
  }
  if (typeof s.slotDurationMs !== "number" || s.slotDurationMs < 100) {
    throw new Error("v3-genesis: slotDurationMs must be >= 100");
  }
  if (typeof s.epochSlots !== "number" || !Number.isInteger(s.epochSlots) || s.epochSlots < 1) {
    throw new Error("v3-genesis: epochSlots must be a positive integer");
  }
  if (typeof s.quorumNumerator !== "number" || typeof s.quorumDenominator !== "number" || s.quorumNumerator <= 0 || s.quorumDenominator <= 0 || s.quorumNumerator >= s.quorumDenominator) {
    throw new Error("v3-genesis: quorum must satisfy 0 < numerator < denominator");
  }
  if (!Array.isArray(s.initialCommittee) || s.initialCommittee.length === 0) {
    throw new Error("v3-genesis: initialCommittee must be a non-empty array");
  }
  for (let i = 0; i < s.initialCommittee.length; i++) {
    const v = s.initialCommittee[i];
    if (typeof v.address_hex !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(v.address_hex)) {
      throw new Error(`v3-genesis: committee[${i}].address_hex must be 0x + 40 hex chars`);
    }
    if (typeof v.stake_wei !== "string" || !/^\d+$/.test(v.stake_wei) || BigInt(v.stake_wei) === 0n) {
      throw new Error(`v3-genesis: committee[${i}].stake_wei must be a positive decimal string`);
    }
    if (v.pubKey_hex !== void 0) {
      if (typeof v.pubKey_hex !== "string" || !/^0x[0-9a-fA-F]{3904}$/.test(v.pubKey_hex)) {
        throw new Error(`v3-genesis: committee[${i}].pubKey_hex must be 0x + 3904 hex chars (Dilithium3 pubkey)`);
      }
    }
    if (v.broadcastUrl !== void 0 && typeof v.broadcastUrl !== "string") {
      throw new Error(`v3-genesis: committee[${i}].broadcastUrl must be a string when set`);
    }
  }
  if (!Array.isArray(s.initialBalances)) {
    throw new Error("v3-genesis: initialBalances must be an array (may be empty)");
  }
  for (let i = 0; i < s.initialBalances.length; i++) {
    const b = s.initialBalances[i];
    if (typeof b.address_hex !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(b.address_hex)) {
      throw new Error(`v3-genesis: balances[${i}].address_hex must be 0x + 40 hex chars`);
    }
    if (typeof b.balance_wei !== "string" || !/^\d+$/.test(b.balance_wei)) {
      throw new Error(`v3-genesis: balances[${i}].balance_wei must be a decimal string`);
    }
  }
  const sc = s.systemContracts;
  if (!sc || typeof sc !== "object") {
    throw new Error("v3-genesis: systemContracts required");
  }
  for (const key6 of ["staking_hex", "slashing_hex", "cron_hex", "vault_hex", "workerRegistry_hex"]) {
    const v = sc[key6];
    if (typeof v !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(v)) {
      throw new Error(`v3-genesis: systemContracts.${key6} must be 0x + 40 hex chars`);
    }
  }
}
function canonicalizeGenesisSpec(spec) {
  const canonical = {
    version: spec.version,
    chainId: spec.chainId,
    chainName: spec.chainName,
    slot0StartUnixMs: spec.slot0StartUnixMs,
    slotDurationMs: spec.slotDurationMs,
    epochSlots: spec.epochSlots,
    quorumNumerator: spec.quorumNumerator,
    quorumDenominator: spec.quorumDenominator,
    initialCommittee: spec.initialCommittee.slice().sort((a, b) => a.address_hex.localeCompare(b.address_hex)).map((v) => ({ address_hex: v.address_hex.toLowerCase(), stake_wei: v.stake_wei })),
    initialBalances: spec.initialBalances.slice().sort((a, b) => a.address_hex.localeCompare(b.address_hex)).map((b) => ({ address_hex: b.address_hex.toLowerCase(), balance_wei: b.balance_wei })),
    systemContracts: {
      staking_hex: spec.systemContracts.staking_hex.toLowerCase(),
      slashing_hex: spec.systemContracts.slashing_hex.toLowerCase(),
      cron_hex: spec.systemContracts.cron_hex.toLowerCase(),
      vault_hex: spec.systemContracts.vault_hex.toLowerCase(),
      workerRegistry_hex: spec.systemContracts.workerRegistry_hex.toLowerCase()
    }
  };
  return new TextEncoder().encode(JSON.stringify(canonical));
}
function genesisSpecHash(spec) {
  return blake3(canonicalizeGenesisSpec(spec));
}
function canonicalCommitteeOrder(members) {
  return members.slice().sort((a, b) => {
    const sa = BigInt(a.stake_wei);
    const sb = BigInt(b.stake_wei);
    if (sb > sa) return 1;
    if (sb < sa) return -1;
    const aa = a.address_hex.toLowerCase();
    const ab = b.address_hex.toLowerCase();
    if (aa < ab) return -1;
    if (aa > ab) return 1;
    return 0;
  });
}
function deriveAuraConfig(spec, selfAddress) {
  const committee = canonicalCommitteeOrder(spec.initialCommittee).map(
    (v) => hexToBytes(v.address_hex)
  );
  return {
    genesisSlot0UnixMs: spec.slot0StartUnixMs,
    slotDurationMs: spec.slotDurationMs,
    graceWindowMs: spec.slotDurationMs,
    committee,
    selfAddress
  };
}
function deriveGrandpaConfig(spec, selfAddress) {
  const ordered = canonicalCommitteeOrder(spec.initialCommittee);
  const committee = ordered.map((v) => hexToBytes(v.address_hex));
  const stake = ordered.map((v) => BigInt(v.stake_wei));
  return {
    committee,
    stake,
    selfAddress,
    quorumNumerator: BigInt(spec.quorumNumerator),
    quorumDenominator: BigInt(spec.quorumDenominator)
  };
}
function buildGenesisHeader(spec) {
  return {
    parentHash: new Uint8Array(32),
    stateRoot: genesisSpecHash(spec),
    transactionsRoot: new Uint8Array(32),
    receiptsRoot: new Uint8Array(32),
    number: 0n,
    slot: 0n,
    timestamp: BigInt(spec.slot0StartUnixMs),
    gasLimit: 30000000n,
    gasUsed: 0n,
    baseFeePerGas: 0n,
    proposer: new Uint8Array(20)
  };
}
function parseGenesisSpec(json) {
  const parsed = JSON.parse(json);
  validateGenesisSpec(parsed);
  return parsed;
}

// src/v3/engine.ts
import { blake3 as blake32 } from "@asentum/crypto";
import { AuraBlockHeaderSchema } from "@asentum/types";

// src/aura/slot.ts
function slotForTime(cfg, unixMs) {
  if (unixMs < cfg.genesisSlot0UnixMs) return 0n;
  const elapsed = unixMs - cfg.genesisSlot0UnixMs;
  return BigInt(Math.floor(elapsed / cfg.slotDurationMs));
}
function slotStartTime(cfg, slot) {
  return cfg.genesisSlot0UnixMs + Number(slot) * cfg.slotDurationMs;
}
function deterministicTimestamp(cfg, slot) {
  return BigInt(slotStartTime(cfg, slot));
}

// src/aura/leader-schedule.ts
function leaderInCommittee(committee, slot) {
  if (committee.length === 0) {
    throw new Error("leaderInCommittee: empty committee");
  }
  const idx = Number(slot % BigInt(committee.length));
  const leader = committee[idx];
  if (!leader) throw new Error(`leaderInCommittee: committee[${idx}] undefined`);
  return leader;
}
function isSelfLeaderInCommittee(committee, self, slot) {
  if (!self) return false;
  const expected = leaderInCommittee(committee, slot);
  if (expected.length !== self.length) return false;
  for (let i = 0; i < expected.length; i++) {
    if (expected[i] !== self[i]) return false;
  }
  return true;
}

// src/aura/producer.ts
var AuraProducer = class {
  config;
  assemble;
  broadcast;
  onProduced;
  onSkipped;
  getHead;
  canProduce;
  now;
  scheduler;
  bootDelayMs;
  startedAt = 0;
  /**
   * Committee schedule for the dynamic validator set. Each entry is the
   * producer committee in force from `fromHeight` (inclusive) until the next
   * entry. The leader for the block at height H is
   * `committeeForHeight(H)[slot % committee.length]`: committee SELECTED by
   * height (so it changes deterministically at an epoch boundary, agreed by
   * every node from H alone), rotation INDEXED by slot (so skipped slots
   * still rotate). Keying selection on height, not wall-clock, means every
   * node computes the same leader for H no matter WHEN it applies the swap,
   * which is what stops an epoch boundary from splitting the leader schedule.
   */
  committeeSchedule;
  lastHandledSlot = -1n;
  tickHandle = null;
  busy = false;
  constructor(opts) {
    this.config = opts.config;
    this.committeeSchedule = [{ fromHeight: 0n, committee: opts.config.committee }];
    this.assemble = opts.assemble;
    this.broadcast = opts.broadcast;
    this.onProduced = opts.onProduced ?? (() => {
    });
    this.onSkipped = opts.onSkipped ?? (() => {
    });
    this.getHead = opts.getHead;
    this.canProduce = opts.canProduce ?? (() => true);
    this.now = opts.now ?? Date.now;
    this.scheduler = opts.scheduler ?? {
      setInterval: (fn, ms) => setInterval(fn, ms),
      clearInterval: (h) => clearInterval(h)
    };
    this.bootDelayMs = opts.bootDelayMs ?? 0;
  }
  /**
   * The producer committee in force for the block at `height`. Linear scan
   * from the newest entry; the schedule is tiny (one entry per epoch).
   */
  committeeForHeight(height) {
    for (let i = this.committeeSchedule.length - 1; i >= 0; i--) {
      const entry = this.committeeSchedule[i];
      if (entry.fromHeight <= height) return entry.committee;
    }
    return this.committeeSchedule[0].committee;
  }
  /**
   * Install a new producer committee effective from block `fromHeight` onward
   * (dynamic validator set). Mirrors FinalityStore.reconfigure so production
   * and finality switch committees at the same height boundary. `fromHeight`
   * must strictly exceed every previously-scheduled fromHeight; committee must
   * be non-empty. Older entries stay in the schedule so late/replayed slots
   * near the boundary still resolve the committee they were produced under.
   */
  reconfigure(fromHeight, committee) {
    if (committee.length === 0) {
      throw new Error("AuraProducer.reconfigure: committee must be non-empty");
    }
    const last = this.committeeSchedule[this.committeeSchedule.length - 1];
    if (fromHeight <= last.fromHeight) {
      throw new Error(
        `AuraProducer.reconfigure: fromHeight ${fromHeight} must exceed last scheduled ${last.fromHeight}`
      );
    }
    this.committeeSchedule.push({ fromHeight, committee: committee.map((c) => c.slice()) });
  }
  /** Start the tick loop. Idempotent. */
  start(tickIntervalMs = 100) {
    if (this.tickHandle !== null) return;
    this.startedAt = this.now();
    this.tickHandle = this.scheduler.setInterval(() => {
      void this.tick();
    }, tickIntervalMs);
  }
  /** Stop the tick loop and release the timer. Idempotent. */
  stop() {
    if (this.tickHandle === null) return;
    this.scheduler.clearInterval(this.tickHandle);
    this.tickHandle = null;
  }
  /**
   * One tick. Public so tests can drive it deterministically without a
   * real timer. Safe to call from anywhere; guards against re-entry.
   */
  async tick() {
    if (this.busy) return;
    const slot = slotForTime(this.config, this.now());
    if (slot <= this.lastHandledSlot) return;
    this.lastHandledSlot = slot;
    if (!this.config.selfAddress) {
      this.onSkipped(slot, "no-self-address");
      return;
    }
    const targetHeight = this.getHead().number + 1n;
    const committee = this.committeeForHeight(targetHeight);
    if (!isSelfLeaderInCommittee(committee, this.config.selfAddress, slot)) {
      this.onSkipped(slot, "not-leader");
      return;
    }
    if (this.bootDelayMs > 0 && this.now() - this.startedAt < this.bootDelayMs) {
      this.onSkipped(slot, "warmup");
      return;
    }
    if (!this.canProduce()) {
      this.onSkipped(slot, "behind");
      return;
    }
    this.busy = true;
    try {
      const head = this.getHead();
      const ctx = {
        slot,
        height: head.number + 1n,
        parentHash: head.hash,
        timestamp: deterministicTimestamp(this.config, slot),
        proposer: this.config.selfAddress
      };
      const block = await this.assemble(ctx);
      if (!block) {
        this.onSkipped(slot, "assemble-failed");
        return;
      }
      this.broadcast(block);
      this.onProduced(block);
    } finally {
      this.busy = false;
    }
  }
  /** Test helper: what slot is currently active? */
  currentSlot() {
    return slotForTime(this.config, this.now());
  }
  /** Test helper: who is the leader right now (for the next block's height)? */
  currentLeader() {
    const committee = this.committeeForHeight(this.getHead().number + 1n);
    return leaderInCommittee(committee, this.currentSlot());
  }
  /** How long until the next slot starts, in ms. Never negative. */
  msToNextSlot() {
    const nextSlot = slotForTime(this.config, this.now()) + 1n;
    return Math.max(0, slotStartTime(this.config, nextSlot) - this.now());
  }
};

// src/aura/fork-choice.ts
function key(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
function bytesEqual(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}
var ForkChoice = class {
  nodesByHash = /* @__PURE__ */ new Map();
  childrenByParent = /* @__PURE__ */ new Map();
  rootHash;
  headHash;
  /**
   * `root` is the last-finalized block (or genesis on cold start). The
   * head initializes to root and moves out as descendants are inserted.
   */
  constructor(root) {
    this.nodesByHash.set(key(root.hash), root);
    this.rootHash = root.hash;
    this.headHash = root.hash;
  }
  /**
   * Insert a new block. Its parent must already be in the tree;
   * otherwise the block is buffered by hash for the caller to retry
   * once the parent lands. (This class does NOT buffer; the caller's
   * sync layer is responsible for delivering parents first.)
   *
   * Returns whether this insert advanced the head.
   */
  insert(node) {
    const nk = key(node.hash);
    if (this.nodesByHash.has(nk)) {
      return { headChanged: false, newHead: this.headHash };
    }
    const pk = key(node.parentHash);
    if (!this.nodesByHash.has(pk)) {
      throw new Error(
        `ForkChoice.insert: parent ${pk.slice(0, 12)} of block ${nk.slice(0, 12)} not in tree`
      );
    }
    this.nodesByHash.set(nk, node);
    const siblings = this.childrenByParent.get(pk) ?? [];
    siblings.push(nk);
    this.childrenByParent.set(pk, siblings);
    const prevHead = this.headHash;
    this.headHash = this.computeHead();
    return {
      headChanged: !bytesEqual(prevHead, this.headHash),
      newHead: this.headHash
    };
  }
  /**
   * Advance the finalized root to `newRoot`. Prunes every subtree that
   * is not a descendant of the new root, including any prior head that
   * lived on a losing fork.
   */
  finalize(newRootHash) {
    const rk = key(newRootHash);
    if (!this.nodesByHash.has(rk)) {
      throw new Error(`ForkChoice.finalize: unknown block ${rk.slice(0, 12)}`);
    }
    const keep = /* @__PURE__ */ new Set();
    const walk = [rk];
    while (walk.length > 0) {
      const cur = walk.pop();
      keep.add(cur);
      const children = this.childrenByParent.get(cur) ?? [];
      for (const c of children) walk.push(c);
    }
    for (const existingKey of Array.from(this.nodesByHash.keys())) {
      if (!keep.has(existingKey)) {
        this.nodesByHash.delete(existingKey);
        this.childrenByParent.delete(existingKey);
      }
    }
    for (const parentKey of Array.from(this.childrenByParent.keys())) {
      if (!keep.has(parentKey)) this.childrenByParent.delete(parentKey);
    }
    this.rootHash = newRootHash;
    this.headHash = this.computeHead();
  }
  /**
   * Discard the whole tree and re-root at `node` (#414 restart recovery).
   * Used on boot to restore the fork-choice to the committed head loaded
   * from the block-store, instead of cold-starting at genesis. After this
   * the tree holds only `node` (as both root and head); descendants arrive
   * via normal gossip/sync and attach to it. Safe because everything at or
   * below the committed head is already finalized on disk.
   */
  reroot(node) {
    this.nodesByHash.clear();
    this.childrenByParent.clear();
    this.nodesByHash.set(key(node.hash), node);
    this.rootHash = node.hash;
    this.headHash = node.hash;
  }
  /** Current head (heaviest descendant of the finalized root). */
  head() {
    return this.headHash;
  }
  /** Current finalized root. */
  root() {
    return this.rootHash;
  }
  /** Get a node by hash. Returns null if not in the tree. */
  get(hash) {
    return this.nodesByHash.get(key(hash)) ?? null;
  }
  /** True if `hash` is a descendant (inclusive) of the finalized root. */
  isCanonical(hash) {
    return this.nodesByHash.has(key(hash));
  }
  /**
   * All direct children of `parent` currently in the tree. Empty array
   * if none. Callers wanting deterministic sibling order should sort
   * the returned array — this method preserves insert order for cheap
   * iteration; the deterministic-tiebreak in computeHead is what makes
   * head-selection agree across nodes, not this accessor.
   *
   * Added to support next-session finality-stall fix
   * (project_v3_working_2026_07_15.md — deterministic sibling
   * tiebreak requires observing all children of finalityHead first).
   */
  childrenOf(parent) {
    const pk = key(parent);
    const childKeys = this.childrenByParent.get(pk) ?? [];
    const out = [];
    for (const ck of childKeys) {
      const node = this.nodesByHash.get(ck);
      if (node) out.push(node);
    }
    return out;
  }
  /** Node count. Useful for telemetry + tests. */
  size() {
    return this.nodesByHash.size;
  }
  /**
   * Walk from the finalized root to the deepest tip. Ties on depth are
   * broken by highest block number, then by lexicographic hash order
   * so the choice is deterministic across nodes.
   */
  computeHead() {
    let best = this.nodesByHash.get(key(this.rootHash));
    if (!best) throw new Error("ForkChoice.computeHead: root missing");
    let bestDepth = 0;
    const stack = [
      { hash: key(this.rootHash), depth: 0 }
    ];
    while (stack.length > 0) {
      const cur = stack.pop();
      const children = this.childrenByParent.get(cur.hash) ?? [];
      if (children.length === 0) {
        const node = this.nodesByHash.get(cur.hash);
        if (!node) continue;
        if (cur.depth > bestDepth || cur.depth === bestDepth && node.number > best.number || cur.depth === bestDepth && node.number === best.number && cur.hash > key(best.hash)) {
          best = node;
          bestDepth = cur.depth;
        }
      } else {
        for (const c of children) stack.push({ hash: c, depth: cur.depth + 1 });
      }
    }
    return best.hash;
  }
};

// src/grandpa/aggregator.ts
var SIGNATURE_BYTES = 3309;
function bytesEqual2(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}
function committeeIndex(committee, addr) {
  for (let i = 0; i < committee.length; i++) {
    const c = committee[i];
    if (c && bytesEqual2(c, addr)) return i;
  }
  return -1;
}
function bitmapBytes(n) {
  return Math.floor((n + 7) / 8);
}
function bitmapGet(bitmap, i) {
  const byte = bitmap[i >>> 3];
  if (byte === void 0) return false;
  return (byte >>> (i & 7) & 1) === 1;
}
function bitmapSet(bitmap, i) {
  const idx = i >>> 3;
  const cur = bitmap[idx] ?? 0;
  bitmap[idx] = cur | 1 << (i & 7);
}
function bitmapPopcount(bitmap) {
  let n = 0;
  for (let i = 0; i < bitmap.length; i++) {
    let b = bitmap[i] ?? 0;
    while (b) {
      b &= b - 1;
      n++;
    }
  }
  return n;
}
function packSignedCommit(cfg, round, target, votes) {
  const bitmap = new Uint8Array(bitmapBytes(cfg.committee.length));
  const included = new Array(cfg.committee.length).fill(null);
  for (const v of votes) {
    if (v.body.round !== round) continue;
    if (v.body.kind !== "precommit") continue;
    if (!bytesEqual2(v.body.target.hash, target.hash)) continue;
    const idx = committeeIndex(cfg.committee, v.body.voter);
    if (idx < 0) continue;
    if (included[idx]) continue;
    if (v.signature.length !== SIGNATURE_BYTES) {
      throw new Error(
        `packSignedCommit: signature length ${v.signature.length} != ${SIGNATURE_BYTES}`
      );
    }
    included[idx] = v;
    bitmapSet(bitmap, idx);
  }
  const setCount = bitmapPopcount(bitmap);
  const signatures = new Uint8Array(setCount * SIGNATURE_BYTES);
  let cursor = 0;
  for (let i = 0; i < included.length; i++) {
    const v = included[i];
    if (!v) continue;
    signatures.set(v.signature, cursor);
    cursor += SIGNATURE_BYTES;
  }
  return { round, target, bitmap, signatures };
}
function unpackSignedCommit(cfg, commit) {
  const out = [];
  const setCount = bitmapPopcount(commit.bitmap);
  const expected = setCount * SIGNATURE_BYTES;
  if (commit.signatures.length !== expected) {
    throw new Error(
      `unpackSignedCommit: signatures length ${commit.signatures.length} != ${expected} for ${setCount} set bits`
    );
  }
  let cursor = 0;
  for (let i = 0; i < cfg.committee.length; i++) {
    if (!bitmapGet(commit.bitmap, i)) continue;
    const voter = cfg.committee[i];
    if (!voter) continue;
    const signature = commit.signatures.slice(cursor, cursor + SIGNATURE_BYTES);
    out.push({ voter, signature });
    cursor += SIGNATURE_BYTES;
  }
  return out;
}

// src/grandpa/round.ts
function key2(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
function totalStake(cfg) {
  let sum = 0n;
  for (const s of cfg.stake) sum += s;
  return sum;
}
function quorumThreshold(cfg) {
  const total = totalStake(cfg);
  return total * cfg.quorumNumerator / cfg.quorumDenominator + 1n;
}
function stakeOf(cfg, voter) {
  for (let i = 0; i < cfg.committee.length; i++) {
    const a = cfg.committee[i];
    if (!a || a.length !== voter.length) continue;
    let match = true;
    for (let j = 0; j < a.length; j++) {
      if (a[j] !== voter[j]) {
        match = false;
        break;
      }
    }
    if (match) return cfg.stake[i] ?? 0n;
  }
  return 0n;
}
function tallyVotes(cfg, votes) {
  const votesPerTarget = /* @__PURE__ */ new Map();
  const votedBy = /* @__PURE__ */ new Set();
  const threshold = quorumThreshold(cfg);
  let totalStakeVoted = 0n;
  let quorumHash = null;
  for (const v of votes) {
    const voterKey = key2(v.body.voter);
    if (votedBy.has(voterKey)) continue;
    const s = stakeOf(cfg, v.body.voter);
    if (s === 0n) continue;
    votedBy.add(voterKey);
    totalStakeVoted += s;
    const targetKey = key2(v.body.target.hash);
    const prior = votesPerTarget.get(targetKey) ?? 0n;
    const next = prior + s;
    votesPerTarget.set(targetKey, next);
    if (!quorumHash && next >= threshold) {
      quorumHash = v.body.target.hash;
    }
  }
  return { totalStakeVoted, votesPerTarget, quorumHash };
}
function isEligibleVote(cfg, vote, expectedRound, expectedKind) {
  if (vote.body.round !== expectedRound) return false;
  if (vote.body.kind !== expectedKind) return false;
  if (vote.body.target.hash.length !== 32) return false;
  return stakeOf(cfg, vote.body.voter) > 0n;
}

// src/grandpa/store.ts
var DEFAULT_MAX_UNFINALIZED_ROUNDS = 1024;
var AUGMENT_FINALIZED_CERT = process.env.ASENTUM_V3_DYNAMIC_COMMITTEE === "1";
var AUGMENT_WINDOW_ROUNDS = (() => {
  const raw = process.env.ASENTUM_V3_AUGMENT_WINDOW;
  if (raw === void 0) return 8;
  const n = Number(raw);
  return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 8;
})();
function envCertGraceMs() {
  const n = Number(process.env.ASENTUM_CERT_GRACE_MS ?? "0");
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
var FinalityStore = class {
  /**
   * Committee/stake schedule for the dynamic validator set. Each entry is
   * the config in force from `fromRound` (inclusive) until the next entry's
   * fromRound. Ordered ascending; always holds at least the genesis entry
   * at fromRound 0. A vote for round R is tallied against `cfgForRound(R)`,
   * never a later committee. See reconfigure()/cfgForRound().
   */
  cfgSchedule;
  lastIneligibleLogMs = 0;
  onFinalize;
  maxUnfinalizedRounds;
  augmentFinalizedCert;
  augmentWindowRounds;
  lastFinalizedRound;
  lastFinalizedTarget;
  commitByRound = /* @__PURE__ */ new Map();
  roundPrevotes = /* @__PURE__ */ new Map();
  roundPrecommits = /* @__PURE__ */ new Map();
  // --- vote-latency metrics (observability only; never feeds consensus).
  // Wall-clock is fine here: these values are served over RPC for diagnosis,
  // never hashed, compared across nodes, or fed into state computation. ---
  metricsWindowRounds;
  /** First-accepted-precommit wall-clock arrival ms, per round per voter hex. */
  precommitArrivalMs = /* @__PURE__ */ new Map();
  /** Ring of consumed rounds (insertion order == ascending round). */
  consumedRounds = /* @__PURE__ */ new Map();
  constructor(cfg, genesis, opts = {}) {
    this.cfgSchedule = [{ fromRound: 0n, cfg }];
    this.onFinalize = opts.onFinalize ?? (() => {
    });
    this.maxUnfinalizedRounds = opts.maxUnfinalizedRounds ?? DEFAULT_MAX_UNFINALIZED_ROUNDS;
    this.augmentFinalizedCert = opts.augmentFinalizedCert ?? (AUGMENT_FINALIZED_CERT || envCertGraceMs() > 0);
    this.augmentWindowRounds = Math.max(1, opts.augmentWindowRounds ?? AUGMENT_WINDOW_ROUNDS);
    this.metricsWindowRounds = Math.max(1, opts.metricsWindowRounds ?? 1e3);
    this.lastFinalizedRound = 0n;
    this.lastFinalizedTarget = { hash: genesis.hash, number: genesis.number };
  }
  /**
   * The committee/stake configuration in force for `round` (= height under
   * height-indexing). Dynamic validator sets change the committee at epoch
   * boundaries via reconfigure(); a vote for round R must always be tallied
   * against the committee active AT R, never a later one, or the quorum
   * denominator would shift mid-flight and finality could never seal the
   * tail rounds of the outgoing committee (finality lags production, so
   * those rounds are still open when the new committee is scheduled). The
   * schedule is tiny (one entry per epoch), so a linear scan from the newest
   * entry is fine.
   */
  cfgForRound(round) {
    for (let i = this.cfgSchedule.length - 1; i >= 0; i--) {
      const entry = this.cfgSchedule[i];
      if (entry.fromRound <= round) return entry.cfg;
    }
    return this.cfgSchedule[0].cfg;
  }
  /**
   * The committee/stake in force at the current finality frontier (the round
   * we are about to finalize next). Exposed for callers that build the reward
   * bitmap or report the active voter set.
   */
  activeCommittee() {
    const cfg = this.cfgForRound(this.lastFinalizedRound + 1n);
    return { committee: cfg.committee, stake: cfg.stake };
  }
  /**
   * Install a new committee + stake set effective from `fromRound` onward
   * (dynamic validator set). Called at an epoch boundary with the
   * deterministic frozen committee for the incoming epoch, at the SAME
   * fromRound on every node, so all nodes tally identical quorums.
   *
   * `fromRound` MUST strictly exceed every previously-scheduled fromRound and
   * should be a future round (>= the next round to finalize), so in-flight
   * rounds keep finalizing under the committee that was live when they were
   * voted. Committee + stake arrays must be equal length and non-empty.
   * quorumNumerator/Denominator and selfAddress are inherited from the
   * previous config (they do not change across reconfiguration).
   */
  reconfigure(fromRound, committee, stake) {
    if (committee.length === 0) {
      throw new Error("reconfigure: committee must be non-empty");
    }
    if (committee.length !== stake.length) {
      throw new Error(
        `reconfigure: committee length ${committee.length} != stake length ${stake.length}`
      );
    }
    const last = this.cfgSchedule[this.cfgSchedule.length - 1];
    if (fromRound <= last.fromRound) {
      throw new Error(
        `reconfigure: fromRound ${fromRound} must exceed last scheduled ${last.fromRound}`
      );
    }
    const base = last.cfg;
    const cfg = {
      committee: committee.map((c) => c.slice()),
      stake: stake.slice(),
      selfAddress: base.selfAddress,
      quorumNumerator: base.quorumNumerator,
      quorumDenominator: base.quorumDenominator
    };
    this.cfgSchedule.push({ fromRound, cfg });
    this.pruneSchedule();
  }
  /**
   * Drop schedule entries fully superseded before the last finalized round.
   * We never tally a round <= lastFinalizedRound (submitVote rejects those as
   * below-finality), so an entry whose successor already covers
   * lastFinalizedRound can never be selected again. Always keeps >= 1 entry.
   */
  pruneSchedule() {
    while (this.cfgSchedule.length > 1 && this.cfgSchedule[1].fromRound <= this.lastFinalizedRound) {
      this.cfgSchedule.shift();
    }
  }
  /**
   * If unfinalized rounds have accumulated past our cap (long stall or
   * partition), drop the lowest-round buckets. The chain cannot
   * finalize a round for which we no longer hold votes anyway, so this
   * is a memory-safety guard, not a correctness change.
   */
  evictStaleRounds() {
    if (this.roundPrevotes.size <= this.maxUnfinalizedRounds && this.roundPrecommits.size <= this.maxUnfinalizedRounds) return;
    const evictBelow = (m) => {
      if (m.size <= this.maxUnfinalizedRounds) return;
      const sorted = Array.from(m.keys()).sort((a, b) => BigInt(a) < BigInt(b) ? -1 : 1);
      const dropCount = m.size - this.maxUnfinalizedRounds;
      for (let i = 0; i < dropCount; i++) m.delete(sorted[i]);
    };
    evictBelow(this.roundPrevotes);
    evictBelow(this.roundPrecommits);
  }
  /** Current finality head. Returned by copy so callers cannot mutate our state. */
  head() {
    return {
      round: this.lastFinalizedRound,
      target: {
        hash: this.lastFinalizedTarget.hash.slice(),
        number: this.lastFinalizedTarget.number
      }
    };
  }
  /**
   * Restore the finalized checkpoint on restart (#414). A rebooted node
   * loads its committed head from the block-store; without this the store
   * resets to genesis (round 0) and the voter re-derives finality from
   * scratch, which under height-indexing means re-voting from height 1 —
   * so the node cannot re-finalize and the cluster wedges. Set the
   * finalized round + target to the persisted head; the voter then votes
   * forward from head+1 exactly as if it had never restarted.
   *
   * Only vote buckets and commit history are lost (they are re-observable
   * from live gossip); the finalized checkpoint is authoritative from the
   * block-store, so this is safe. No-op if `round` is not ahead of our
   * current finalized round (never move finality backwards).
   */
  restoreFinalized(round, target) {
    if (round <= this.lastFinalizedRound) return;
    this.lastFinalizedRound = round;
    this.lastFinalizedTarget = { hash: target.hash.slice(), number: target.number };
  }
  /**
   * #418 finality-catchup: adopt a finalized commit cert as a JUSTIFICATION —
   * the way a light client / catching-up node imports finality without re-running
   * the two-phase vote. A SignedCommit already proves a precommit quorum for its
   * target; unlike submitVote (which additionally gates on a local PREVOTE quorum
   * this node never receives over pull-only HTTP — the cert carries no prevotes),
   * this trusts the self-contained precommit proof and fires onFinalize so the
   * engine drives ForkChoice + reward hooks exactly as a live finalize would.
   *
   * Signature authenticity (ML-DSA) is verified by the CALLER before this runs
   * (the store holds no pubkeys). Here we independently re-check the structural
   * precommit-stake quorum against the committee in force at the cert's round, so
   * a cert that does not actually carry a quorum can never move finality even if
   * the caller's checks regress. Returns the commit when finality advanced, or
   * null when the cert is stale (round <= lastFinalized) or fails its own quorum.
   */
  importCommit(commit) {
    if (commit.round <= this.lastFinalizedRound && this.lastFinalizedRound > 0n) return null;
    const cfg = this.cfgForRound(commit.round);
    let votes;
    try {
      votes = unpackSignedCommit(cfg, commit).map((e) => ({
        body: { round: commit.round, kind: "precommit", target: commit.target, voter: e.voter },
        signature: e.signature
      }));
    } catch {
      return null;
    }
    const tally = tallyVotes(cfg, votes);
    if (!tally.quorumHash || !bytesEqual3(tally.quorumHash, commit.target.hash)) return null;
    this.commitByRound.set(String(commit.round), commit);
    this.lastFinalizedRound = commit.round;
    this.lastFinalizedTarget = { hash: commit.target.hash.slice(), number: commit.target.number };
    for (const k of Array.from(this.roundPrevotes.keys())) {
      if (BigInt(k) <= commit.round) this.roundPrevotes.delete(k);
    }
    for (const k of Array.from(this.roundPrecommits.keys())) {
      if (BigInt(k) <= commit.round) this.roundPrecommits.delete(k);
    }
    this.onFinalize(commit);
    return commit;
  }
  /** True if `round` has been finalized. */
  isFinalized(round) {
    return this.commitByRound.has(String(round));
  }
  /** Retrieve the SignedCommit for a finalized round, or null. */
  commitAt(round) {
    return this.commitByRound.get(String(round)) ?? null;
  }
  /**
   * All prevotes received for `round` (= height under height-indexing).
   * Exposed so the vote-target resolver can adopt the block the network
   * is converging on when this node's own fork-choice head sits on a
   * losing fork and would otherwise abstain (#415 abstention fix). Read
   * only; returns the live array reference's contents by copy.
   */
  prevotesForRound(round) {
    return (this.roundPrevotes.get(String(round)) ?? []).slice();
  }
  /**
   * The prevote target that has crossed the 2/3 stake threshold (a polka)
   * for a round, or null if no target has quorum yet. A voter precommits
   * the polka target so every node commits the same block.
   */
  prevotePolka(round) {
    const prevotes = this.roundPrevotes.get(String(round)) ?? [];
    const tally = tallyVotes(this.cfgForRound(round), prevotes);
    if (!tally.quorumHash) return null;
    const match = prevotes.find(
      (v) => bytesEqual3(v.body.target.hash, tally.quorumHash)
    );
    return match ? match.body.target : null;
  }
  /**
   * Ingest a signed vote. Returns whether it was accepted; when the
   * vote closes out a precommit quorum, the returned SignedCommit
   * carries the aggregate finality proof.
   *
   * Assumes the ML-DSA signature was already verified upstream. The
   * store enforces round-membership, voter-membership, and dedup.
   */
  submitVote(vote) {
    if (vote.body.round <= this.lastFinalizedRound && this.lastFinalizedRound > 0n) {
      const augmented = this.maybeAugmentFinalizedCert(vote);
      if (augmented) return { accepted: true, finalized: augmented };
      return { accepted: false, reason: "below-finality" };
    }
    if (vote.body.kind !== "prevote" && vote.body.kind !== "precommit") {
      return { accepted: false, reason: "wrong-kind" };
    }
    const cfg = this.cfgForRound(vote.body.round);
    if (!isEligibleVote(cfg, vote, vote.body.round, vote.body.kind)) {
      const nowMs = Date.now();
      if (process.env.ASENTUM_GRANDPA_DEBUG || nowMs - this.lastIneligibleLogMs > 1e4) {
        this.lastIneligibleLogMs = nowMs;
        const vx = Buffer.from(vote.body.voter).toString("hex").slice(0, 8);
        const inCommittee = cfg.committee.some(
          (c) => Buffer.from(c).toString("hex") === Buffer.from(vote.body.voter).toString("hex")
        );
        console.log(
          `[vote-ineligible] round=${vote.body.round} kind=${vote.body.kind} voter=${vx} committeeSize=${cfg.committee.length} voterInCommittee=${inCommittee}`
        );
      }
      return { accepted: false, reason: "ineligible-voter" };
    }
    const bucket = vote.body.kind === "prevote" ? this.roundPrevotes : this.roundPrecommits;
    const key6 = String(vote.body.round);
    const list = bucket.get(key6) ?? [];
    const existingIdx = list.findIndex((v) => sameVoter(v, vote));
    if (existingIdx >= 0) {
      const existing = list[existingIdx];
      if (bytesEqual3(existing.body.target.hash, vote.body.target.hash)) {
        return { accepted: false, reason: "duplicate" };
      }
      if (vote.body.kind === "precommit") {
        return { accepted: false, reason: "duplicate" };
      }
      list.splice(existingIdx, 1);
    }
    list.push(vote);
    bucket.set(key6, list);
    this.evictStaleRounds();
    if (vote.body.kind !== "precommit") return { accepted: true };
    this.recordPrecommitArrival(vote);
    const prevotes = this.roundPrevotes.get(key6) ?? [];
    const prevoteTally = tallyVotes(cfg, prevotes);
    if (!prevoteTally.quorumHash) return { accepted: true };
    if (!bytesEqual3(prevoteTally.quorumHash, vote.body.target.hash)) {
      return { accepted: true };
    }
    const precommitTally = tallyVotes(cfg, list);
    if (process.env.ASENTUM_GRANDPA_DEBUG) {
      const voters = list.map((v) => Buffer.from(v.body.voter).toString("hex").slice(0, 6)).join(",");
      const totalStake2 = cfg.stake.reduce((a, b) => a + b, 0n);
      console.log(
        `[precommit-tally] round=${vote.body.round} precommits=${list.length}/${cfg.committee.length} precommitQuorum=${!!precommitTally.quorumHash} totalStake=${totalStake2} voters=${voters}`
      );
    }
    if (!precommitTally.quorumHash) return { accepted: true };
    if (!bytesEqual3(precommitTally.quorumHash, vote.body.target.hash)) {
      return { accepted: true };
    }
    const commit = packSignedCommit(
      cfg,
      vote.body.round,
      vote.body.target,
      list
    );
    this.commitByRound.set(key6, commit);
    this.lastFinalizedRound = vote.body.round;
    this.lastFinalizedTarget = vote.body.target;
    this.roundPrevotes.delete(key6);
    if (!this.augmentFinalizedCert) this.roundPrecommits.delete(key6);
    for (const k of Array.from(this.roundPrevotes.keys())) {
      if (BigInt(k) <= vote.body.round) this.roundPrevotes.delete(k);
    }
    const windowFloor = this.augmentFinalizedCert ? vote.body.round - BigInt(this.augmentWindowRounds - 1) : vote.body.round;
    for (const k of Array.from(this.roundPrecommits.keys())) {
      if (BigInt(k) < windowFloor) this.roundPrecommits.delete(k);
    }
    this.onFinalize(commit);
    return { accepted: true, finalized: commit };
  }
  /**
   * Fold a late precommit into the cert of the round we most recently
   * finalized, re-packing a richer SignedCommit. Returns the updated commit
   * when the vote was folded in, or null when it does not qualify (so the
   * caller falls back to the normal below-finality rejection).
   *
   * Qualifies only when: augmentation is enabled; the vote is a precommit for
   * exactly the last-finalized round on the SAME finalized target; the voter is
   * eligible in that round's committee; and the voter is not already present in
   * the round's precommit bucket (no equivocation, no duplicate). The re-packed
   * commit is re-emitted through onFinalize so the node's cached cert
   * (v3LastFinalizedCert) picks up the extra bit before the next block consumes
   * it. Determinism is unaffected: rewards are reconstructed from the committed
   * block's bitmap, and only the proposer's local cert feeds that bitmap.
   */
  maybeAugmentFinalizedCert(vote) {
    if (!this.augmentFinalizedCert) return null;
    if (vote.body.kind !== "precommit") return null;
    if (this.lastFinalizedRound === 0n) return null;
    if (vote.body.round > this.lastFinalizedRound) return null;
    if (vote.body.round < this.lastFinalizedRound - BigInt(this.augmentWindowRounds - 1)) {
      return null;
    }
    const key6 = String(vote.body.round);
    const existingCommit = this.commitByRound.get(key6);
    if (!existingCommit) return null;
    if (!bytesEqual3(vote.body.target.hash, existingCommit.target.hash)) return null;
    const cfg = this.cfgForRound(vote.body.round);
    if (!isEligibleVote(cfg, vote, vote.body.round, vote.body.kind)) return null;
    const list = this.roundPrecommits.get(key6) ?? [];
    if (list.some((v) => sameVoter(v, vote))) return null;
    list.push(vote);
    this.roundPrecommits.set(key6, list);
    this.recordPrecommitArrival(vote);
    const commit = packSignedCommit(cfg, vote.body.round, existingCommit.target, list);
    this.commitByRound.set(key6, commit);
    this.onFinalize(commit);
    return commit;
  }
  /**
   * Metrics only: note the wall-clock arrival of a voter's first accepted
   * precommit for a round. When the round's cert was ALREADY consumed by the
   * producer path, the vote is late — it can still be cert-augmented, but it
   * missed the block that baked this round's bitmap, so mark it as such.
   */
  recordPrecommitArrival(vote) {
    const key6 = String(vote.body.round);
    const voter = hexOfAddr(vote.body.voter);
    const nowMs = Date.now();
    let byVoter = this.precommitArrivalMs.get(key6);
    if (!byVoter) {
      byVoter = /* @__PURE__ */ new Map();
      this.precommitArrivalMs.set(key6, byVoter);
    }
    if (!byVoter.has(voter)) byVoter.set(voter, nowMs);
    const consumed = this.consumedRounds.get(key6);
    if (consumed) {
      const entry = consumed.perVoter.get(voter);
      if (entry && entry.arrivalMs === null) {
        entry.arrivalMs = nowMs;
        entry.late = true;
      }
    }
    while (this.precommitArrivalMs.size > this.metricsWindowRounds + 64) {
      const oldest = this.precommitArrivalMs.keys().next().value;
      if (oldest === void 0) break;
      this.precommitArrivalMs.delete(oldest);
    }
  }
  /**
   * Producer-path hook (metrics only): the proposer is baking `bitmap` — the
   * cert for `round` — into the next block's reward bitmap. Snapshot, per
   * committee member of that round, whether their precommit had arrived by now
   * and how long it sat waiting. First consumption wins: the same cert is
   * re-consumed unchanged when this proposer builds several consecutive blocks
   * before finality advances, and re-recording would skew the delay stats.
   */
  recordCertConsumption(round, bitmap) {
    const key6 = String(round);
    if (this.consumedRounds.has(key6)) return;
    const consumedMs = Date.now();
    const cfg = this.cfgForRound(round);
    const arrivals = this.precommitArrivalMs.get(key6);
    const perVoter = /* @__PURE__ */ new Map();
    for (let i = 0; i < cfg.committee.length; i++) {
      const voter = hexOfAddr(cfg.committee[i]);
      perVoter.set(voter, {
        arrivalMs: arrivals?.get(voter) ?? null,
        included: bitmapGet(bitmap, i),
        late: false
      });
    }
    this.consumedRounds.set(key6, { consumedMs, perVoter });
    while (this.consumedRounds.size > this.metricsWindowRounds) {
      const oldest = this.consumedRounds.keys().next().value;
      if (oldest === void 0) break;
      this.consumedRounds.delete(oldest);
      this.precommitArrivalMs.delete(oldest);
    }
  }
  /**
   * Aggregate the consumed-round ring per voter. Read-only snapshot for
   * GET /v3/vote-metrics. `inclusionRatio` is denominated in the rounds the
   * voter was actually eligible in (committee membership changes per epoch),
   * so a freshly-promoted external is not diluted by pre-promotion rounds.
   */
  voteMetrics() {
    const agg = /* @__PURE__ */ new Map();
    for (const entry of this.consumedRounds.values()) {
      for (const [voter, v] of entry.perVoter) {
        let a = agg.get(voter);
        if (!a) {
          a = { rounds: 0, arrived: 0, included: 0, late: 0, delaySum: 0, delayCount: 0, maxDelay: 0 };
          agg.set(voter, a);
        }
        a.rounds++;
        if (v.arrivalMs !== null) a.arrived++;
        if (v.included) a.included++;
        if (v.late) a.late++;
        if (v.arrivalMs !== null && !v.late) {
          const d = entry.consumedMs - v.arrivalMs;
          a.delaySum += d;
          a.delayCount++;
          if (d > a.maxDelay) a.maxDelay = d;
        }
      }
    }
    const head = this.head();
    const perVoter = Array.from(agg.entries()).sort(([a], [b]) => a < b ? -1 : 1).map(([address, a]) => ({
      address: `0x${address}`,
      votesArrived: a.arrived,
      included: a.included,
      late: a.late,
      avgDelayMs: a.delayCount > 0 ? Math.round(a.delaySum / a.delayCount) : null,
      maxDelayMs: a.delayCount > 0 ? a.maxDelay : null,
      inclusionRatio: a.rounds > 0 ? a.included / a.rounds : 0,
      eligibleRounds: a.rounds
    }));
    return {
      head: { round: head.round.toString(), number: head.target.number.toString() },
      window: this.consumedRounds.size,
      perVoter
    };
  }
  /** Vote counts for a given round + kind. Diagnostic helper. */
  voteCount(round, kind) {
    const bucket = kind === "prevote" ? this.roundPrevotes : this.roundPrecommits;
    return (bucket.get(String(round)) ?? []).length;
  }
};
function sameVoter(a, b) {
  if (a.body.voter.length !== b.body.voter.length) return false;
  for (let i = 0; i < a.body.voter.length; i++) {
    if (a.body.voter[i] !== b.body.voter[i]) return false;
  }
  return true;
}
function hexOfAddr(b) {
  let s = "";
  for (let i = 0; i < b.length; i++) s += (b[i] ?? 0).toString(16).padStart(2, "0");
  return s;
}
function bytesEqual3(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}

// src/grandpa/voter.ts
var GrandpaVoter = class {
  cfg;
  store;
  getVoteTarget;
  signVote;
  broadcast;
  restorePriorVote;
  now;
  scheduler;
  local;
  tickHandle = null;
  busy = false;
  constructor(opts) {
    this.cfg = opts.config;
    this.store = opts.store;
    this.getVoteTarget = opts.getVoteTarget;
    this.signVote = opts.signVote;
    this.broadcast = opts.broadcast;
    this.restorePriorVote = opts.restorePriorVote;
    this.now = opts.now ?? Date.now;
    this.scheduler = opts.scheduler ?? {
      setInterval: (fn, ms) => setInterval(fn, ms),
      clearInterval: (h) => clearInterval(h)
    };
    this.local = { instance: 0n, target: null, prevoteCast: false, precommitCast: false };
  }
  /** Start the tick. Idempotent. */
  start(tickIntervalMs = 100) {
    if (this.tickHandle !== null) return;
    this.tickHandle = this.scheduler.setInterval(() => {
      void this.tick();
    }, tickIntervalMs);
  }
  /** Stop the tick. Idempotent. */
  stop() {
    if (this.tickHandle === null) return;
    this.scheduler.clearInterval(this.tickHandle);
    this.tickHandle = null;
  }
  /**
   * One step of the height-indexed finality loop. Callable by tests
   * without a real timer. Re-entry guarded so overlapping ticks never
   * double-sign for the same instance.
   *
   * Each tick: derive the next height to finalize from agreed finalized
   * state (H = finalizedHeight + 1). If finality advanced since last
   * tick, reset local vote state to the new instance. Then cast a
   * prevote, then a precommit, for the deterministic block at height H.
   * The store finalizes when both a prevote and precommit quorum land on
   * the same hash; the next tick moves to H+1. No timers, no round
   * drift.
   */
  async tick() {
    if (this.busy) return;
    this.busy = true;
    try {
      const finalityHead = this.store.head();
      const nextHeight = finalityHead.target.number + 1n;
      if (this.local.instance !== nextHeight) {
        this.local = {
          instance: nextHeight,
          target: null,
          prevoteCast: false,
          precommitCast: false
        };
      }
      if (!this.cfg.selfAddress) return;
      const fresh = await this.getVoteTarget(nextHeight);
      if (process.env.ASENTUM_GRANDPA_DEBUG) {
        const hx = (b) => b ? Buffer.from(b).toString("hex").slice(0, 8) : "null";
        const pv = this.store.voteCount(nextHeight, "prevote");
        const pc = this.store.voteCount(nextHeight, "precommit");
        const polka = this.store.prevotePolka(nextHeight);
        console.log(
          `[grandpa-debug] h=${nextHeight} target=${hx(fresh?.hash)} prevotes=${pv} precommits=${pc} polka=${hx(polka?.hash)} myTarget=${hx(this.local.target?.hash)} precommitCast=${this.local.precommitCast}`
        );
      }
      if (!fresh || !fresh.hash) return;
      if (!this.local.precommitCast) {
        const changed = !this.local.target || !bytesEqual4(this.local.target.hash, fresh.hash);
        if (!this.local.prevoteCast || changed) {
          const prior = !this.local.prevoteCast ? this.restorePriorVote?.(nextHeight, "prevote") ?? null : null;
          const voteTarget = prior && prior.hash ? prior : fresh;
          this.local.target = voteTarget;
          const body = {
            round: nextHeight,
            kind: "prevote",
            target: voteTarget,
            voter: this.cfg.selfAddress
          };
          const signature = await this.signVote(body);
          const signed = { body, signature };
          this.store.submitVote(signed);
          this.broadcast(signed);
          this.local.prevoteCast = true;
        }
        const polka = this.store.prevotePolka(nextHeight);
        if (polka) {
          const prior = this.restorePriorVote?.(nextHeight, "precommit") ?? null;
          const voteTarget = prior && prior.hash ? prior : polka;
          this.local.target = voteTarget;
          const body = {
            round: nextHeight,
            kind: "precommit",
            target: voteTarget,
            voter: this.cfg.selfAddress
          };
          const signature = await this.signVote(body);
          const signed = { body, signature };
          this.store.submitVote(signed);
          this.broadcast(signed);
          this.local.precommitCast = true;
        }
        return;
      }
    } finally {
      this.busy = false;
    }
  }
  /**
   * The height instance the voter is currently working to finalize:
   * finalizedHeight + 1. Derived from the store so it reflects finality
   * the instant a peer vote finalizes a height, without waiting for the
   * next tick to update local bookkeeping.
   */
  round() {
    return this.store.head().target.number + 1n;
  }
  /**
   * Called by the transport layer when a peer's vote arrives. Delegates
   * to the store, which finalizes internally on quorum. The next tick
   * observes the advanced finalized head and moves to the next height
   * instance — no explicit advance needed. Returns the store's result
   * so callers can log outcomes.
   */
  ingestPeerVote(vote) {
    const r = this.store.submitVote(vote);
    return { accepted: r.accepted, finalized: r.finalized };
  }
};
function bytesEqual4(a, b) {
  if (!a || !b || a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}

// src/aura/slashing.ts
function bytesEqual5(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}
function headersIdentical(a, b) {
  return bytesEqual5(a.parentHash, b.parentHash) && bytesEqual5(a.stateRoot, b.stateRoot) && bytesEqual5(a.transactionsRoot, b.transactionsRoot) && bytesEqual5(a.receiptsRoot, b.receiptsRoot) && a.number === b.number && a.slot === b.slot && a.timestamp === b.timestamp && a.gasLimit === b.gasLimit && a.gasUsed === b.gasUsed && a.baseFeePerGas === b.baseFeePerGas && bytesEqual5(a.proposer, b.proposer);
}
function detectAuraEquivocation(a, b) {
  if (a.slot !== b.slot) return null;
  if (!bytesEqual5(a.proposer, b.proposer)) return null;
  if (headersIdentical(a, b)) return null;
  return {
    slot: a.slot,
    offender: a.proposer,
    headerA: a,
    headerB: b
  };
}

// src/aura/header-history.ts
function key3(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
var HeaderHistory = class {
  byProposer = /* @__PURE__ */ new Map();
  lastFinalizedSlot = -1n;
  /**
   * Ingest a header. Returns evidence when the header equivocates
   * against any prior header from the same proposer at the same slot
   * within the retention window. Returns null on clean ingest or
   * byte-identical replay of a known header.
   */
  add(header) {
    if (header.slot <= this.lastFinalizedSlot) return null;
    const pk = key3(header.proposer);
    const list = this.byProposer.get(pk) ?? [];
    for (const prior of list) {
      const ev = detectAuraEquivocation(prior, header);
      if (ev) return ev;
    }
    list.push(header);
    this.byProposer.set(pk, list);
    return null;
  }
  /**
   * Advance the retention watermark. Every header at or below `slot`
   * drops from memory.
   */
  onFinalize(slot) {
    if (slot <= this.lastFinalizedSlot) return;
    this.lastFinalizedSlot = slot;
    for (const [pk, list] of Array.from(this.byProposer.entries())) {
      const kept = list.filter((h) => h.slot > slot);
      if (kept.length === 0) this.byProposer.delete(pk);
      else this.byProposer.set(pk, kept);
    }
  }
  /** Diagnostic accessor: how many headers are held for a proposer. */
  sizeFor(proposer) {
    return (this.byProposer.get(key3(proposer)) ?? []).length;
  }
  /** Diagnostic accessor: total headers held. */
  totalSize() {
    let n = 0;
    for (const list of this.byProposer.values()) n += list.length;
    return n;
  }
};

// src/grandpa/slashing.ts
function bytesEqual6(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}
function detectGrandpaEquivocation(a, b) {
  if (!bytesEqual6(a.body.voter, b.body.voter)) return null;
  if (a.body.round !== b.body.round) return null;
  if (a.body.kind !== b.body.kind) return null;
  if (bytesEqual6(a.body.target.hash, b.body.target.hash)) return null;
  return {
    kind: "equivocation",
    round: a.body.round,
    voteKind: a.body.kind,
    offender: a.body.voter,
    voteA: a,
    voteB: b
  };
}
function detectGrandpaSurround(a, b) {
  if (!bytesEqual6(a.body.voter, b.body.voter)) return null;
  if (a.body.round === b.body.round) return null;
  if (bytesEqual6(a.body.target.hash, b.body.target.hash)) return null;
  const earlier = a.body.round < b.body.round ? a : b;
  const later = a.body.round < b.body.round ? b : a;
  if (earlier.body.target.number <= later.body.target.number) return null;
  return {
    kind: "surround",
    offender: a.body.voter,
    earlier,
    later
  };
}

// src/grandpa/vote-history.ts
function key4(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
var VoteHistory = class {
  byVoter = /* @__PURE__ */ new Map();
  lastFinalizedRound = 0n;
  /**
   * Ingest a vote. Returns evidence if the vote conflicts with any
   * prior vote from the same voter in the retention window. Returns
   * null when the vote is a clean addition.
   */
  add(vote) {
    if (vote.body.round <= this.lastFinalizedRound) return null;
    const vk = key4(vote.body.voter);
    const list = this.byVoter.get(vk) ?? [];
    for (const prior of list) {
      const eq = detectGrandpaEquivocation(prior, vote);
      if (eq) return eq;
      const sr = detectGrandpaSurround(prior, vote);
      if (sr) return sr;
    }
    list.push(vote);
    this.byVoter.set(vk, list);
    return null;
  }
  /**
   * Advance the retention watermark. Every vote at or below `round`
   * drops from memory since it cannot produce actionable evidence
   * against post-finalization slashing (retention would be nice-to-
   * have for a longer window but is a v3.1 problem).
   */
  onFinalize(round) {
    if (round <= this.lastFinalizedRound) return;
    this.lastFinalizedRound = round;
    for (const [vk, list] of Array.from(this.byVoter.entries())) {
      const kept = list.filter((v) => v.body.round > round);
      if (kept.length === 0) this.byVoter.delete(vk);
      else this.byVoter.set(vk, kept);
    }
  }
  /**
   * Would signing `candidate` conflict with anything WE have signed?
   * Local-voter guard: returns evidence when the candidate would
   * equivocate against our own prior vote. Never surfaces surround
   * against ourselves because a well-behaved local voter follows the
   * finality chain by definition.
   */
  wouldSelfEquivocate(candidate) {
    const vk = key4(candidate.body.voter);
    const list = this.byVoter.get(vk) ?? [];
    for (const prior of list) {
      const eq = detectGrandpaEquivocation(prior, candidate);
      if (eq) return eq;
    }
    return null;
  }
  /** How many votes are held for a given voter. Diagnostic. */
  sizeFor(voter) {
    return (this.byVoter.get(key4(voter)) ?? []).length;
  }
  /** Total votes held. Diagnostic. */
  totalSize() {
    let n = 0;
    for (const list of this.byVoter.values()) n += list.length;
    return n;
  }
};

// src/v3/slashing-reporter.ts
function key5(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
var SlashingReporter = class {
  headerHistory = new HeaderHistory();
  voteHistory = new VoteHistory();
  submit;
  emittedOffenders = /* @__PURE__ */ new Set();
  auraEmitCount = 0;
  grandpaEmitCount = 0;
  constructor(opts) {
    this.submit = opts.submitSlash;
  }
  /**
   * Ingest an Aura header freshly seen off the wire or produced locally.
   *
   * Dedup is by offender-only, not by (offender, slot). Once we submit
   * one slash exhibit for an offender the on-chain slashing contract
   * tombstones them; submitting a second exhibit at a later slot is
   * wasted gas + noise. If they equivocate on some fresh spec after
   * we've forgotten, the on-chain check would reject the redundant tx.
   */
  observeHeader(header) {
    const ev = this.headerHistory.add(header);
    if (!ev) return;
    if (this.emittedOffenders.has(key5(ev.offender))) return;
    this.emittedOffenders.add(key5(ev.offender));
    this.auraEmitCount += 1;
    this.submit({ source: "aura", evidence: ev });
  }
  /**
   * Ingest a GRANDPA vote freshly seen off the wire or produced locally.
   * Dedup semantics match observeHeader — one exhibit per offender for
   * the reporter's lifetime.
   */
  observeVote(vote) {
    const ev = this.voteHistory.add(vote);
    if (!ev) return;
    if (this.emittedOffenders.has(key5(ev.offender))) return;
    this.emittedOffenders.add(key5(ev.offender));
    this.grandpaEmitCount += 1;
    this.submit({ source: "grandpa", evidence: ev });
  }
  /**
   * Advance the finality watermark on both histories. Called from the
   * v3 finality hook: after GRANDPA finalizes round R for a block at
   * slot S, retention drops everything at or below (S, R).
   */
  onFinalize(atSlot, atRound) {
    this.headerHistory.onFinalize(atSlot);
    this.voteHistory.onFinalize(atRound);
  }
  /**
   * Local-voter guard used by the producer + voter before signing.
   * Returns true when signing `vote` would equivocate against our
   * own prior vote and the signer MUST abort.
   */
  wouldSelfEquivocate(vote) {
    return this.voteHistory.wouldSelfEquivocate(vote) !== null;
  }
  /** Diagnostic snapshot. Useful for tests and RPC surfacing. */
  stats() {
    return {
      headersHeld: this.headerHistory.totalSize(),
      votesHeld: this.voteHistory.totalSize(),
      auraEmits: this.auraEmitCount,
      grandpaEmits: this.grandpaEmitCount,
      emittedOffenders: this.emittedOffenders.size
    };
  }
};

// src/v3/engine.ts
var V3Engine = class _V3Engine {
  /**
   * How many descendants the fork head must have above a height before
   * we vote to finalize it. This is the re-org safety margin: a voter
   * only votes for the block at height H once the head is at least this
   * many blocks past H, by which point deterministic fork-choice has
   * converged on the single canonical block at H across all honest
   * nodes, so they all vote the SAME target and never split.
   *
   * MUST be > 0. With lag=1 (the first cut, 2026-07-15) a cross-DC Aura
   * tip fork at height H could split the vote before fork-choice
   * converged: different nodes prevoted different siblings at H, the
   * self-vote-record locked each to its choice, and with no round
   * dimension to re-vote the converged value, H never reached quorum,
   * finality wedged at H while Aura raced ahead (soak wedge at h=402).
   * Larger lag lets the fork resolve BEFORE anyone votes, so the split
   * can't happen. 6 ≈ 18s of finality lag at 3s slots, comfortable for
   * checkpoint finality, and single-leader Aura resolves tip forks in
   * 1-2 blocks so 6 is well clear of the frontier.
   *
   * Tunable via ASENTUM_V3_FINALITY_LAG for cross-DC latency headroom.
   */
  static FINALITY_LAG = BigInt(process.env.ASENTUM_V3_FINALITY_LAG ?? "6");
  /** Cap on buffered orphan blocks (missing-parent). Drops oldest past this. */
  static MAX_ORPHANS = 512;
  /** How many recent accepted blocks we retain to serve peers' sync requests. */
  static RECENT_RING = 512;
  /** Min gap between our own sync requests, ms, avoids request storms. */
  static SYNC_REQUEST_COOLDOWN_MS = 2e3;
  /**
   * How long a buffered orphan may sit undrained before it is evicted, ms.
   * A REAL forward gap fills in a few sync rounds (a couple of seconds); an
   * orphan still buffered after this window is a DEAD FORK — its parent is a
   * block the canonical chain never adopted, so drainOrphansOf can never fire
   * for it and its parent never arrives from block-sync (peers serve the
   * canonical block at that height, a different hash). Left forever it pins
   * isBehind() true, which holds Aura production off PERMANENTLY: after the
   * 2026-08-15 fork/reconverge incident three baked each held such an orphan
   * above head, skipped every leader slot with reason=behind, and the cluster
   * collapsed to a single producer (~18-20s/block). Evicting is safe — the
   * orphan buffer is a receive cache, not consensus state; if the gap is real
   * the next above-head block re-buffers and block-sync re-fetches it. */
  static ORPHAN_STALE_MS = Number(process.env.ASENTUM_V3_ORPHAN_STALE_MS ?? "20000");
  hooks;
  producer;
  voter;
  forkChoice;
  finalityStore;
  slashingReporter;
  headHash;
  started = false;
  /**
   * Cert-grace: how many finalized rounds the producer path lags cert
   * consumption by (ceil(ASENTUM_CERT_GRACE_MS / slotDurationMs), capped).
   * 0n = grace disabled, consumption reads the latest cert exactly as before.
   */
  certGraceRounds;
  /** #418: when true, never produce Aura blocks (pull-mode follower). */
  suppressProduction;
  /** Injectable clock for orphan-staleness eviction; Date.now in production. */
  now;
  /** Throttle for the #454 unknown-finalize-target log (one line / 10s). */
  lastUnknownFinalizeLogMs = 0;
  // --- block-sync / orphan recovery (task #410) ---
  /** Blocks whose parent isn't in the fork tree yet, keyed by parent-hash hex. */
  orphansByParent = /* @__PURE__ */ new Map();
  orphanTotal = 0;
  /** When each buffered orphan (keyed by its own block-hash hex) was first
   * buffered, ms. Drives ORPHAN_STALE_MS eviction of never-draining dead-fork
   * orphans that would otherwise pin isBehind() true forever. */
  orphanBufferedAt = /* @__PURE__ */ new Map();
  /** Highest block height seen from any peer (incl. orphans), behind-detection. */
  maxSeenHeight = 0n;
  /** Ring of recently accepted blocks by height, to serve peers' sync requests. */
  recentBlocks = /* @__PURE__ */ new Map();
  lastSyncRequestAt = 0;
  // --- single-flight peer apply ---
  /**
   * Block hashes currently queued/applying, so N peers gossiping the same
   * block cost ONE apply, not N. Every ingress fires onPeerBlock unawaited
   * (ANT frame handlers, sync-request replay, pull-mode), so without this a
   * block arriving from several peers ran the full rebuild+re-execute+verify
   * concurrently per copy — ruinous while a behind node catches up (each
   * apply is the expensive path) and the reason catchup CPU scaled with peer
   * count instead of block count.
   */
  applyInFlight = /* @__PURE__ */ new Set();
  /**
   * FIFO serialization of applies. applyBlock re-executes against shared
   * node state; overlapping applies of DIFFERENT blocks interleave awaits
   * inside the apply path, so at most one runs at a time. Chain tail is
   * error-swallowed — a rejected block must not poison the queue.
   */
  applyTail = Promise.resolve();
  constructor(opts) {
    this.hooks = opts.hooks;
    this.suppressProduction = opts.suppressProduction ?? false;
    this.now = opts.now ?? (() => Date.now());
    const graceMs = opts.certGraceMs ?? envCertGraceMs();
    this.certGraceRounds = graceMs > 0 ? BigInt(
      Math.min(6, Math.max(1, Math.ceil(graceMs / opts.auraConfig.slotDurationMs)))
    ) : 0n;
    const genesisHash = blake32(AuraBlockHeaderSchema.serialize(opts.genesis.header));
    this.headHash = genesisHash;
    const genesisNode = {
      hash: genesisHash,
      parentHash: opts.genesis.header.parentHash,
      number: opts.genesis.header.number,
      slot: opts.genesis.header.slot,
      proposer: opts.genesis.header.proposer
    };
    this.forkChoice = new ForkChoice(genesisNode);
    this.finalityStore = new FinalityStore(
      opts.grandpaConfig,
      { hash: genesisHash, number: opts.genesis.header.number },
      {
        onFinalize: (commit) => {
          if (this.forkChoice.get(commit.target.hash)) {
            this.forkChoice.finalize(commit.target.hash);
          } else {
            const nowMs = Date.now();
            if (nowMs - this.lastUnknownFinalizeLogMs > 1e4) {
              this.lastUnknownFinalizeLogMs = nowMs;
              console.log(
                `[v3-finalize-defer] round=${commit.round} target=${this.hexOf(commit.target.hash).slice(0, 12)} not in fork tree (behind or augment-of-older-round); fork-choice catches up on apply`
              );
            }
          }
          this.slashingReporter.onFinalize(this.headSlot(), commit.round);
          this.hooks.onFinalize?.(commit);
        },
        // Cert-grace requires the ingest-side augment path (a late precommit
        // must fold into its round's cert to be consumable at all). Leave
        // undefined when grace is off so the store's own default (dynamic-
        // committee flag / env) applies unchanged.
        ...this.certGraceRounds > 0n ? { augmentFinalizedCert: true } : {}
      }
    );
    this.slashingReporter = new SlashingReporter({
      submitSlash: (exhibit) => this.hooks.submitSlashExhibit(exhibit)
    });
    this.producer = new AuraProducer({
      config: opts.auraConfig,
      // 5000ms boot delay so the ANT peer mesh has time to stabilize
      // before we produce. Without it, block 1 produced during
      // peer-connect churn can be lost, GRANDPA can't reach quorum,
      // chain silently stalls at h=0 (v2 latestHeader stays at
      // genesis while Aura keeps producing invisibly into
      // forkChoice). See notes/v3-aura-startup-race-2026-07-14.md.
      bootDelayMs: 5e3,
      assemble: (ctx) => this.assemble(ctx),
      broadcast: (b) => this.hooks.broadcastBlock(b),
      onProduced: (b) => {
        this.slashingReporter.observeHeader(b.header);
        this.acceptBlockLocal(b);
      },
      onSkipped: (slot, reason) => {
        const slotNum = Number(slot % 500n);
        if (slot < 30n || slotNum === 0) {
          console.log(`[v3-aura-skip] slot=${slot} reason=${reason}`);
        }
      },
      getHead: () => {
        const node = this.forkChoice.get(this.headHash);
        return {
          number: node?.number ?? opts.genesis.header.number,
          hash: this.headHash
        };
      },
      // Hold our slot while we have a fork-tree gap (missed block). See
      // isBehind, producing while behind forks off canonical + spams
      // peers with un-insertable blocks (the h=711 straggler wedge).
      // #418: a pull-mode (NAT'd) validator NEVER produces — it can't
      // broadcast, and self-produced fork blocks tangle with imported certs.
      canProduce: () => !this.suppressProduction && !this.isBehind()
    });
    this.voter = new GrandpaVoter({
      config: opts.grandpaConfig,
      store: this.finalityStore,
      // Height-indexed target (2026-07-15 rewrite). Given the height H
      // the voter wants to finalize next, return the deterministic block
      // at height H: the ancestor of the current fork head at that
      // height. Because every honest node runs the same deterministic
      // Aura schedule + the same fork-choice, they all resolve the same
      // block at H, so their per-height votes co-tally regardless of
      // arrival timing. This replaces the free-running-round /
      // childrenOf(finalized) design whose per-node round timers drifted
      // apart under cross-DC latency and made quorum impossible (silent
      // finality stall, notes/v3-consensus-audit-2026-07-15.md).
      getVoteTarget: async (height) => this.voteTargetAtHeight(height),
      signVote: opts.hooks.signVote,
      broadcast: (v) => {
        this.slashingReporter.observeVote(v);
        this.hooks.broadcastVote(v);
      },
      restorePriorVote: opts.hooks.restorePriorVote ? (round, kind) => {
        const prior = opts.hooks.restorePriorVote(round, kind);
        return prior ? { hash: prior.hash, number: prior.number } : null;
      } : void 0
    });
  }
  /**
   * #414 restart recovery. Re-root the engine at the committed head loaded
   * from the block-store, BEFORE start(). Without this, a rebooted node's
   * fork-choice + finality store cold-start at genesis and re-sync from
   * height 1 via gossip at ~1 block/8s, the head crawls up while the real
   * committed height sits on disk, so the node cannot re-finalize and a
   * rolling restart wedges the whole cluster (2026-07-15). After re-rooting,
   * incoming blocks (head+1, head+2, ...) attach normally and the voter
   * resumes finalizing from head+1 as if it never restarted.
   *
   * `head` is the AuraBlockHeader of the last committed block; node.ts
   * replays the aura-hash chain from persisted v2 headers (which carry
   * `slot`) to build it, cheap, hashing only. No-op at genesis.
   */
  reconstruct(head) {
    if (head.number === 0n) return;
    if (this.started) throw new Error("V3Engine.reconstruct must be called before start()");
    const headHash = blake32(AuraBlockHeaderSchema.serialize(head));
    this.forkChoice.reroot({
      hash: headHash,
      parentHash: head.parentHash,
      number: head.number,
      slot: head.slot,
      proposer: head.proposer
    });
    this.headHash = headHash;
    if (head.number > this.maxSeenHeight) this.maxSeenHeight = head.number;
    this.finalityStore.restoreFinalized(head.number, { hash: headHash, number: head.number });
    console.log(`[v3-reconstruct] re-rooted engine at committed head number=${head.number} slot=${head.slot} hash=${this.hexOf(headHash).slice(0, 12)}`);
  }
  /**
   * Enact a validator-set change at an epoch boundary (dynamic validator set,
   * #424). Switches BOTH the producer's leader schedule and the finality
   * store's quorum committee to `committee`/`stake`, effective from block
   * `fromHeight`. node.ts calls this deterministically at the SAME fromHeight
   * on every node, the frozen committee for the incoming epoch (#419), so the
   * whole cluster rotates the authority set in lockstep. The voter needs no
   * change: it votes with its fixed selfAddress and the store gates eligibility
   * per height. `fromHeight` is a future block (>= next height to produce), so
   * in-flight blocks + the finality tail still resolve under the outgoing set.
   *
   * `leaderCommittee` (project_v3_deadslot): the producer's Aura leader
   * rotation can be a DIFFERENT (dormancy-filtered) subset of `committee` so the
   * slot schedule skips never-signing validators instead of dead-slotting
   * through them, WITHOUT changing the finality/reward committee (which stays
   * the raw `committee` so the cert bitmap encode/decode + reward reconstruction
   * are byte-identical to before). Both are deterministic + identical on every
   * node. Omitted → producer uses `committee` (pre-deadslot behavior).
   */
  reconfigureCommittee(fromHeight, committee, stake, leaderCommittee) {
    this.producer.reconfigure(fromHeight, leaderCommittee ?? committee);
    this.finalityStore.reconfigure(fromHeight, committee, stake);
  }
  /** The committee/stake at the current finality frontier. Exposed for RPC + node.ts. */
  activeCommittee() {
    return this.finalityStore.activeCommittee();
  }
  /** Start both engines. Idempotent. */
  start() {
    if (this.started) return;
    this.producer.start();
    this.voter.start();
    this.started = true;
  }
  /** Stop both engines. Idempotent. */
  stop() {
    if (!this.started) return;
    this.producer.stop();
    this.voter.stop();
    this.started = false;
  }
  /**
   * Called by node.ts's network layer when a peer block arrives.
   * Runs it through applyBlock hook + slashing reporter + fork choice.
   *
   * Block-sync (task #410): if the block's parent is not yet in our fork
   * tree, we have a gap, buffer the block as an orphan and (throttled)
   * ask peers to re-send the missing range. Once the parent lands, the
   * orphan drains automatically. Without this, a single dropped ANT
   * frame permanently strands the node (h=711 soak wedge).
   */
  async onPeerBlock(block) {
    this.slashingReporter.observeHeader(block.header);
    if (block.header.number > this.maxSeenHeight) this.maxSeenHeight = block.header.number;
    const hash = blake32(AuraBlockHeaderSchema.serialize(block.header));
    if (this.forkChoice.get(hash)) return;
    const hex = this.hexOf(hash);
    if (this.applyInFlight.has(hex)) return;
    this.applyInFlight.add(hex);
    const prev = this.applyTail;
    const run = (async () => {
      await prev;
      try {
        if (this.forkChoice.get(hash)) return;
        if (block.header.number > 0n && !this.forkChoice.get(block.header.parentHash)) {
          this.bufferOrphan(block);
          this.maybeRequestGap(block.header.number);
          return;
        }
        const ok = await this.hooks.applyBlock(block);
        if (!ok) return;
        this.acceptBlockLocal(block, hash);
        await this.drainOrphansOf(hash);
      } finally {
        this.applyInFlight.delete(hex);
      }
    })();
    this.applyTail = run.catch(() => {
    });
    return run;
  }
  /**
   * Behind-detection: true when this node has a CONCRETE gap in its fork
   * tree, i.e. it holds buffered orphans (blocks it received but cannot
   * attach because an ancestor is missing). While behind, the producer
   * holds its slot so it doesn't fork off the canonical chain and spam
   * peers; block-sync fills the gap, the orphans drain, and it resumes.
   *
   * IMPORTANT: this is keyed ONLY on the orphan buffer, NOT on a
   * "maxSeenHeight vs head" comparison. maxSeenHeight is a never-
   * decreasing high-water mark; gating production on it created a
   * self-reinforcing deadlock, a single out-of-order block observation
   * ratcheted maxSeenHeight above every node's head, all nodes then
   * refused to produce, so the head could never catch up and the whole
   * cluster froze (chaos-restart wedge at h=793). Orphan-count is precise:
   * it is >0 exactly when there is a real missing-parent gap, and it
   * returns to 0 the instant the gap heals.
   */
  isBehind() {
    this.pruneStaleOrphans();
    if (this.orphanTotal === 0) return false;
    const headHeight = this.forkChoice.get(this.headHash)?.number ?? 0n;
    for (const list of this.orphansByParent.values()) {
      for (const b of list) {
        if (b.header.number > headHeight) return true;
      }
    }
    return false;
  }
  /** Stash an orphan block under its parent-hash; evict oldest past the cap. */
  bufferOrphan(block) {
    const pk = this.hexOf(block.header.parentHash);
    const list = this.orphansByParent.get(pk) ?? [];
    const h = this.hexOf(blake32(AuraBlockHeaderSchema.serialize(block.header)));
    if (list.some((b) => this.hexOf(blake32(AuraBlockHeaderSchema.serialize(b.header))) === h)) return;
    list.push(block);
    this.orphansByParent.set(pk, list);
    this.orphanBufferedAt.set(h, this.now());
    this.orphanTotal++;
    if (this.orphanTotal > _V3Engine.MAX_ORPHANS) this.evictOldestOrphan();
  }
  /** Drop one orphan bucket to bound memory (oldest map insertion order). */
  evictOldestOrphan() {
    const firstKey = this.orphansByParent.keys().next().value;
    if (firstKey === void 0) return;
    const bucket = this.orphansByParent.get(firstKey) ?? [];
    for (const b of bucket) {
      this.orphanBufferedAt.delete(this.hexOf(blake32(AuraBlockHeaderSchema.serialize(b.header))));
    }
    this.orphansByParent.delete(firstKey);
    this.orphanTotal -= bucket.length;
    if (this.orphanTotal < 0) this.orphanTotal = 0;
  }
  /**
   * Evict orphans buffered longer than ORPHAN_STALE_MS. A real forward gap
   * drains within a few block-sync rounds; anything still buffered past the
   * window is a dead fork whose parent will never arrive (peers serve the
   * canonical block at that height, a different hash), so it would pin
   * isBehind() true forever. Removing it is safe — the buffer is a receive
   * cache, not consensus state — and if the gap were real the next above-head
   * block re-buffers it. Called from isBehind() before it reads orphanTotal.
   */
  pruneStaleOrphans() {
    if (this.orphanTotal === 0) return;
    const cutoff = this.now() - _V3Engine.ORPHAN_STALE_MS;
    for (const [pk, list] of this.orphansByParent) {
      const kept = list.filter((b) => {
        const h = this.hexOf(blake32(AuraBlockHeaderSchema.serialize(b.header)));
        const at = this.orphanBufferedAt.get(h) ?? this.now();
        if (at <= cutoff) {
          this.orphanBufferedAt.delete(h);
          return false;
        }
        return true;
      });
      if (kept.length === list.length) continue;
      this.orphanTotal -= list.length - kept.length;
      if (kept.length === 0) this.orphansByParent.delete(pk);
      else this.orphansByParent.set(pk, kept);
    }
    if (this.orphanTotal < 0) this.orphanTotal = 0;
  }
  /**
   * After inserting a block, replay any orphans that were waiting on it.
   * Runs breadth-first so a whole buffered run heals in order.
   */
  async drainOrphansOf(justInserted) {
    const queue = [this.hexOf(justInserted)];
    while (queue.length > 0) {
      const pk = queue.shift();
      const waiting = this.orphansByParent.get(pk);
      if (!waiting) continue;
      this.orphansByParent.delete(pk);
      this.orphanTotal -= waiting.length;
      if (this.orphanTotal < 0) this.orphanTotal = 0;
      for (const orphan of waiting) {
        const oHash = blake32(AuraBlockHeaderSchema.serialize(orphan.header));
        this.orphanBufferedAt.delete(this.hexOf(oHash));
        if (this.forkChoice.get(oHash)) continue;
        const ok = await this.hooks.applyBlock(orphan);
        if (!ok) continue;
        this.acceptBlockLocal(orphan, oHash);
        queue.push(this.hexOf(oHash));
      }
    }
  }
  /**
   * Ask peers (throttled) to re-send the blocks between our head and the
   * orphan we just buffered, so the gap fills. node.ts turns this into an
   * ANT SYNC_REQUEST broadcast; peers answer from their recent-block ring.
   */
  maybeRequestGap(orphanHeight) {
    if (!this.hooks.requestBlocks) return;
    const now = Date.now();
    if (now - this.lastSyncRequestAt < _V3Engine.SYNC_REQUEST_COOLDOWN_MS) return;
    this.lastSyncRequestAt = now;
    const head = this.forkChoice.get(this.headHash);
    const from = (head?.number ?? 0n) + 1n;
    const to = orphanHeight > from ? orphanHeight : from;
    this.hooks.requestBlocks(from, to);
  }
  /**
   * Serve a peer's sync request: return the accepted blocks we hold in
   * [fromHeight, toHeight]. node.ts calls this on an inbound SYNC_REQUEST
   * and ships the results back as normal block frames.
   */
  serveBlocks(fromHeight, toHeight) {
    const out = [];
    let h = fromHeight;
    while (h <= toHeight && out.length < _V3Engine.RECENT_RING) {
      const b = this.recentBlocks.get(h);
      if (b) out.push(b);
      h += 1n;
    }
    return out;
  }
  /**
   * Called by node.ts's network layer when a peer vote arrives.
   * Runs it through slashing reporter + voter (which routes to store).
   * Returns the store's ingest result so callers that need it (the HTTP
   * precommit-relay decision) can distinguish an ACCEPTED vote — fresh or
   * folded in by cert-augmentation — from a rejected one. Existing callers
   * ignore the return value; behavior is unchanged.
   */
  onPeerVote(vote) {
    this.slashingReporter.observeVote(vote);
    return this.voter.ingestPeerVote(vote);
  }
  /** Current head hash. Exposed for RPC surface. */
  getHead() {
    const node = this.forkChoice.get(this.headHash);
    return { number: node?.number ?? 0n, hash: this.headHash };
  }
  /** Last-finalized checkpoint. Exposed for RPC. */
  getFinalityHead() {
    const h = this.finalityStore.head();
    return { round: h.round, hash: h.target.hash, number: h.target.number };
  }
  /** SlashingReporter stats. Exposed for RPC + telemetry. */
  getSlashingStats() {
    return this.slashingReporter.stats();
  }
  /**
   * Metrics only: node.ts calls this at the exact moment the producer path
   * bakes the last-finalized cert into the next block's reward bitmap, so the
   * store can score each committee member's precommit as on-time/late/missing.
   */
  recordCertConsumption(round, bitmap) {
    this.finalityStore.recordCertConsumption(round, bitmap);
  }
  /** The SignedCommit finalizing `round`, or null. Diagnostic + grace path. */
  commitAt(round) {
    return this.finalityStore.commitAt(round);
  }
  /**
   * #418 finality-catchup: import a finalized commit cert (fetched over HTTP by
   * a pull-mode node) as a justification. Delegates to the store's importCommit,
   * whose onFinalize fires the SAME engine handler a live finalize does — driving
   * ForkChoice.finalize + slashing + reward hooks. Caller must ML-DSA-verify the
   * cert's signatures first. Returns the commit when finality advanced, else null.
   *
   * STRICT ORDERING: never advance finality past a block we have not yet applied.
   * A pull node applies block H optimistically (proposal-pull) and only THEN
   * imports H's cert. Without this guard the store's finalized round races ahead
   * of the applied head, the voter's next-height derivation (finalized+1) jumps
   * into an un-applied gap, and block application strands — the #418 rig showed
   * the applied tip frozen at 1 while the store "finalized" 59. Gating on block
   * presence keeps finality one step behind the applied head, exactly as the
   * baked live path does. Safe because importFinalityCommit is catchup-only.
   */
  importFinalityCommit(commit) {
    if (!this.forkChoice.get(commit.target.hash)) return null;
    return this.finalityStore.importCommit(commit);
  }
  /**
   * Cert-grace (ASENTUM_CERT_GRACE_MS): choose which round's cert the
   * producer bakes into the next block's reward bitmap.
   *
   * Default (grace off): `latest` — the just-finalized cert the node cached,
   * byte-identical to today.
   *
   * With grace on, return the cert for `latest.round - certGraceRounds`,
   * read FRESH from the store so every precommit folded in by
   * maybeAugmentFinalizedCert up to this instant is packed. Why lagging the
   * round (not sleeping in the slot) is the fix: an HTTP pull external's
   * precommit for round R is TRIGGERED by R's finalization (GET /v3/proposal
   * only serves finalized heights), and R's cert is consumed by the first
   * block produced after R finalizes — both events are slot-production-
   * driven, so a wall-clock production delay shifts trigger and consumption
   * equally and the inclusion window stays exactly one slot. Consuming a
   * k-round-older cert instead widens the window to (k+1) slots: the
   * straggler folds into its round's still-open cert (augment window) and is
   * present when consumption finally reads that round.
   *
   * Determinism: proposer-local choice feeding ONLY the bitmap this proposer
   * packs into its own block header; every node re-derives rewards from the
   * on-chain header bitmap, never from local certs, so mixed grace settings
   * across the fleet cannot diverge state (same guarantee the augment path
   * already relies on). Falls back to `latest` when the lagged round is not
   * in the commit history (early chain / post-restart) — the pre-grace
   * behavior.
   */
  certForConsumption(latest) {
    if (this.certGraceRounds === 0n) return latest;
    const targetRound = latest.round - this.certGraceRounds;
    if (targetRound < 1n) return latest;
    return this.finalityStore.commitAt(targetRound) ?? latest;
  }
  /** Vote-latency metrics snapshot. Exposed for GET /v3/vote-metrics. */
  voteMetrics() {
    return this.finalityStore.voteMetrics();
  }
  async assemble(ctx) {
    const body = await this.hooks.assembleBlockBody(ctx);
    const header = {
      parentHash: ctx.parentHash,
      stateRoot: body.stateRoot,
      transactionsRoot: body.transactionsRoot,
      receiptsRoot: body.receiptsRoot,
      number: ctx.height,
      slot: ctx.slot,
      timestamp: ctx.timestamp,
      gasLimit: body.gasLimit,
      gasUsed: body.gasUsed,
      baseFeePerGas: body.baseFeePerGas,
      proposer: ctx.proposer
    };
    return { header, bodyBytes: body.bodyBytes };
  }
  acceptBlockLocal(block, precomputedHash) {
    const hash = precomputedHash ?? blake32(AuraBlockHeaderSchema.serialize(block.header));
    if (this.forkChoice.get(hash)) return;
    try {
      const r = this.forkChoice.insert({
        hash,
        parentHash: block.header.parentHash,
        number: block.header.number,
        slot: block.header.slot,
        proposer: block.header.proposer
      });
      if (r.headChanged) this.headHash = r.newHead;
      this.recentBlocks.set(block.header.number, block);
      if (block.header.number > this.maxSeenHeight) this.maxSeenHeight = block.header.number;
      this.evictRecentBelow(block.header.number - BigInt(_V3Engine.RECENT_RING));
    } catch (err) {
      if (err.message.includes("not in tree")) {
        this.bufferOrphan(block);
        this.maybeRequestGap(block.header.number);
        return;
      }
      throw err;
    }
  }
  /** Drop recent-ring entries at/below `belowHeight` to bound memory. */
  evictRecentBelow(belowHeight) {
    if (belowHeight <= 0n) return;
    for (const h of this.recentBlocks.keys()) {
      if (h <= belowHeight) this.recentBlocks.delete(h);
    }
  }
  /** Full-length hex of a byte array (map key). */
  hexOf(b) {
    let s = "";
    for (let i = 0; i < b.length; i++) s += (b[i] ?? 0).toString(16).padStart(2, "0");
    return s;
  }
  headSlot() {
    return this.forkChoice.get(this.headHash)?.slot ?? 0n;
  }
  /**
   * The block to vote for at `height`. #415: prefer the target the network's
   * prevotes are converging on (plurality/GHOST) whenever we can see and hold
   * it, falling back to our own fork-choice ancestor before any peer prevotes
   * exist. The old code returned only the local ancestor, which under a fork
   * meant two nodes prevoted a losing-fork block at H forever and no height
   * reached 2/3, the intermittent finality stall. Trusting the plurality
   * pulls every node onto one block at H even when their heads disagree; peers
   * only prevoted it after burying it >=lag deep, so it is re-org-stable, and
   * we only adopt a target we actually hold. Prevotes are updatable and the
   * precommit is polka-gated in the voter, so converging here is safe.
   */
  voteTargetAtHeight(height) {
    const t = this.pluralityTargetAtHeight(height) ?? this.localAncestorAtHeight(height);
    if (process.env.ASENTUM_GRANDPA_DEBUG && !t) {
      const head = this.forkChoice.get(this.headHash);
      console.log(
        `[vote-target-null] h=${height} head=${head?.number ?? "none"} need>=${height + _V3Engine.FINALITY_LAG} isBehind=${this.isBehind()} orphans=${this.orphanTotal}`
      );
    }
    return t;
  }
  /**
   * The ancestor of our current fork head at `height`, once buried at least
   * FINALITY_LAG descendants deep so fork-choice will not swap it out from
   * under us. Null when the head is not deep enough or has no block at H.
   */
  localAncestorAtHeight(height) {
    const head = this.forkChoice.get(this.headHash);
    if (!head) return null;
    if (head.number < height + _V3Engine.FINALITY_LAG) return null;
    let cur = head;
    while (cur && cur.number > height) {
      cur = this.forkChoice.get(cur.parentHash);
    }
    if (!cur || cur.number !== height) return null;
    return { hash: cur.hash, number: cur.number };
  }
  /**
   * The block at `height` that the most peer prevotes point to (lowest hash
   * breaks ties, so every node picks the same one), provided we hold it in our
   * fork tree. Null when no peer prevotes exist yet or we lack the block.
   */
  pluralityTargetAtHeight(height) {
    const votes = this.finalityStore.prevotesForRound(height);
    if (votes.length === 0) return null;
    const tally = /* @__PURE__ */ new Map();
    for (const v of votes) {
      if (v.body.target.number !== height) continue;
      const k = this.hexOf(v.body.target.hash);
      const e = tally.get(k) ?? { count: 0, hash: v.body.target.hash, number: v.body.target.number };
      e.count += 1;
      tally.set(k, e);
    }
    let best = null;
    for (const e of tally.values()) {
      if (!best || e.count > best.count || e.count === best.count && this.hexOf(e.hash) < this.hexOf(best.hash)) {
        best = e;
      }
    }
    if (best && this.forkChoice.get(best.hash)) return { hash: best.hash, number: best.number };
    return null;
  }
};

// src/v3/self-vote-record.ts
import { open, writeFile, readFile, mkdir } from "fs/promises";
import { dirname } from "path";
function bytesToHex(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
function hexToBytes2(hex) {
  const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
  const bytes = new Uint8Array(clean.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
  }
  return bytes;
}
function recordKey(k) {
  return `${k.round}|${k.kind}|${k.voter}`;
}
var WouldEquivocateError = class extends Error {
  constructor(round, kind, voter, priorTargetHash, candidateTargetHash) {
    super(
      `self-vote-record: would equivocate at round ${round} ${kind} \u2014 prior target ${bytesToHex(
        priorTargetHash
      ).slice(0, 12)} vs candidate ${bytesToHex(candidateTargetHash).slice(0, 12)}`
    );
    this.round = round;
    this.kind = kind;
    this.voter = voter;
    this.priorTargetHash = priorTargetHash;
    this.candidateTargetHash = candidateTargetHash;
  }
  round;
  kind;
  voter;
  priorTargetHash;
  candidateTargetHash;
};
var SelfVoteRecord = class {
  path;
  byKey = /* @__PURE__ */ new Map();
  loaded = false;
  constructor(path) {
    this.path = path;
  }
  /**
   * Load prior votes from disk. Idempotent. Missing file is treated as
   * an empty record; a corrupt line at the tail is a fatal error to
   * force the operator to notice, since silently forgetting a vote is
   * the exact class of bug this module exists to prevent.
   */
  async load() {
    if (this.loaded) return;
    let raw;
    try {
      raw = await readFile(this.path, "utf8");
    } catch (err) {
      if (err.code === "ENOENT") {
        await mkdir(dirname(this.path), { recursive: true });
        this.loaded = true;
        return;
      }
      throw err;
    }
    const lines = raw.split("\n").filter((l) => l.length > 0);
    for (const line of lines) {
      const rec = JSON.parse(line);
      const key6 = recordKey({
        round: BigInt(rec.round),
        kind: rec.kind,
        voter: rec.voter_hex
      });
      this.byKey.set(key6, rec);
    }
    this.loaded = true;
  }
  /**
   * Record a vote we are about to sign. Throws WouldEquivocateError if
   * a prior vote at the same (round, kind, voter) targets a DIFFERENT
   * block hash — the caller MUST abort the sign in that case. Same-
   * target replay is idempotent and returns without writing.
   */
  async record(vote) {
    if (!this.loaded) {
      throw new Error("self-vote-record: load() must be called before record()");
    }
    const voterHex = bytesToHex(vote.body.voter);
    const key6 = recordKey({
      round: vote.body.round,
      kind: vote.body.kind,
      voter: voterHex
    });
    const targetHex = bytesToHex(vote.body.target.hash);
    const existing = this.byKey.get(key6);
    if (existing) {
      if (existing.target_hash_hex === targetHex) return;
      if (vote.body.kind === "precommit") {
        throw new WouldEquivocateError(
          vote.body.round,
          vote.body.kind,
          vote.body.voter,
          hexToBytes2(existing.target_hash_hex),
          vote.body.target.hash
        );
      }
    }
    const rec = {
      round: vote.body.round.toString(),
      kind: vote.body.kind,
      target_hash_hex: targetHex,
      target_number: vote.body.target.number.toString(),
      voter_hex: voterHex
    };
    await this.appendFsync(JSON.stringify(rec) + "\n");
    this.byKey.set(key6, rec);
  }
  /**
   * Return the (targetHash, targetNumber) we previously signed at
   * (round, kind) for `voter`, or null if none. Enables restart-safety
   * in the GrandpaVoter: on mid-round restart the voter adopts this
   * prior target instead of asking getVoteTarget for a fresh answer
   * (which would differ because the fork tree state has moved on),
   * avoiding a WouldEquivocateError storm.
   */
  getPrior(round, kind, voter) {
    if (!this.loaded) return null;
    const key6 = recordKey({ round, kind, voter: bytesToHex(voter) });
    const rec = this.byKey.get(key6);
    if (!rec) return null;
    return {
      targetHash: hexToBytes2(rec.target_hash_hex),
      targetNumber: BigInt(rec.target_number)
    };
  }
  /**
   * Read-only check without recording. Returns true when signing
   * `candidate` would equivocate against the disk record. Used by
   * pre-flight guards; the actual sign path should call `record()`
   * so the guard and the write are atomic.
   */
  wouldEquivocate(candidate) {
    if (!this.loaded) return false;
    const key6 = recordKey({
      round: candidate.body.round,
      kind: candidate.body.kind,
      voter: bytesToHex(candidate.body.voter)
    });
    const existing = this.byKey.get(key6);
    if (!existing) return false;
    if (candidate.body.kind !== "precommit") return false;
    return existing.target_hash_hex !== bytesToHex(candidate.body.target.hash);
  }
  /**
   * Drop records at or below the finalized round. Called after every
   * GRANDPA finalization so the file cannot grow unbounded across
   * long uptimes.
   */
  async prune(belowRound) {
    if (!this.loaded) return;
    let changed = false;
    for (const [k, v] of Array.from(this.byKey.entries())) {
      if (BigInt(v.round) <= belowRound) {
        this.byKey.delete(k);
        changed = true;
      }
    }
    if (!changed) return;
    const lines = [];
    for (const rec of this.byKey.values()) lines.push(JSON.stringify(rec));
    await writeFile(this.path, lines.length > 0 ? lines.join("\n") + "\n" : "");
  }
  /** Diagnostic accessor. */
  size() {
    return this.byKey.size;
  }
  /**
   * Append a line + fsync so a crash between record() returning and
   * the caller broadcasting cannot lose the vote from disk.
   */
  async appendFsync(line) {
    await mkdir(dirname(this.path), { recursive: true });
    const fh = await open(this.path, "a");
    try {
      await fh.write(line);
      await fh.sync();
    } finally {
      await fh.close();
    }
  }
};

// src/v3/finality-journal.ts
import { open as open2, writeFile as writeFile2, readFile as readFile2, mkdir as mkdir2 } from "fs/promises";
import { dirname as dirname2 } from "path";
function bytesToHex2(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
function hexToBytes3(hex) {
  const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
  const bytes = new Uint8Array(clean.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
  }
  return bytes;
}
function serialize(c) {
  return {
    round: c.round.toString(),
    target_hash_hex: bytesToHex2(c.target.hash),
    target_number: c.target.number.toString(),
    bitmap_hex: bytesToHex2(c.bitmap),
    signatures_hex: bytesToHex2(c.signatures)
  };
}
function deserialize(j) {
  return {
    round: BigInt(j.round),
    target: {
      hash: hexToBytes3(j.target_hash_hex),
      number: BigInt(j.target_number)
    },
    bitmap: hexToBytes3(j.bitmap_hex),
    signatures: hexToBytes3(j.signatures_hex)
  };
}
var FinalityJournal = class {
  path;
  byRound = /* @__PURE__ */ new Map();
  highestRound = 0n;
  loaded = false;
  constructor(path) {
    this.path = path;
  }
  /**
   * Load prior commits from disk. Missing file is treated as an empty
   * journal. A corrupt tail line is fatal so the operator notices —
   * silently forgetting a commit is exactly the failure this module
   * prevents.
   */
  async load() {
    if (this.loaded) return;
    let raw;
    try {
      raw = await readFile2(this.path, "utf8");
    } catch (err) {
      if (err.code === "ENOENT") {
        await mkdir2(dirname2(this.path), { recursive: true });
        this.loaded = true;
        return;
      }
      throw err;
    }
    const lines = raw.split("\n").filter((l) => l.length > 0);
    for (const line of lines) {
      const j = JSON.parse(line);
      const commit = deserialize(j);
      this.byRound.set(String(commit.round), commit);
      if (commit.round > this.highestRound) this.highestRound = commit.round;
    }
    this.loaded = true;
  }
  /**
   * Append a commit. Idempotent by round — the same round appended
   * twice with a matching target hash is a no-op; a conflicting target
   * for the same round throws (which would be a v3 protocol violation
   * or a corrupted journal, not a user error).
   */
  async append(commit) {
    if (!this.loaded) {
      throw new Error("finality-journal: load() must be called before append()");
    }
    const key6 = String(commit.round);
    const existing = this.byRound.get(key6);
    if (existing) {
      if (bytesEqual7(existing.target.hash, commit.target.hash)) return;
      throw new Error(
        `finality-journal: conflicting commit at round ${commit.round}: existing target ${bytesToHex2(existing.target.hash).slice(0, 12)} vs new ${bytesToHex2(commit.target.hash).slice(0, 12)}`
      );
    }
    const j = serialize(commit);
    await this.appendFsync(JSON.stringify(j) + "\n");
    this.byRound.set(key6, commit);
    if (commit.round > this.highestRound) this.highestRound = commit.round;
  }
  /** Highest finalized round observed. 0n if journal is empty. */
  highestFinalizedRound() {
    return this.highestRound;
  }
  /** SignedCommit for a specific round, or null. */
  commitAt(round) {
    return this.byRound.get(String(round)) ?? null;
  }
  /**
   * All commits in ascending round order. Used at boot to hand back
   * to a store that wants to rebuild in-memory finality state.
   */
  all() {
    return Array.from(this.byRound.values()).sort(
      (a, b) => a.round < b.round ? -1 : a.round > b.round ? 1 : 0
    );
  }
  /** Number of finalized commits currently held. */
  size() {
    return this.byRound.size;
  }
  /**
   * Drop commits strictly below `keepFromRound` inclusive. The disk
   * file is rewritten with only the retained commits. Called on every
   * finalize by bootV3 to keep the journal bounded — retain the last
   * finalized commit so restart still knows where to resume.
   */
  async prune(belowRound) {
    if (!this.loaded) return;
    let changed = false;
    for (const [k] of Array.from(this.byRound.entries())) {
      if (BigInt(k) < belowRound) {
        this.byRound.delete(k);
        changed = true;
      }
    }
    if (!changed) return;
    const lines = [];
    for (const commit of this.all()) {
      lines.push(JSON.stringify(serialize(commit)));
    }
    await writeFile2(this.path, lines.length > 0 ? lines.join("\n") + "\n" : "");
  }
  async appendFsync(line) {
    await mkdir2(dirname2(this.path), { recursive: true });
    const fh = await open2(this.path, "a");
    try {
      await fh.write(line);
      await fh.sync();
    } finally {
      await fh.close();
    }
  }
};
function bytesEqual7(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}

// src/v3/boot.ts
function wrapSignVote(inner, record) {
  return async (body) => {
    const snapshot = {
      round: body.round,
      kind: body.kind,
      target: {
        hash: body.target.hash.slice(),
        number: body.target.number
      },
      voter: body.voter.slice()
    };
    const preview = {
      body: snapshot,
      signature: new Uint8Array(0)
    };
    await record.record(preview);
    return inner(body);
  };
}
async function bootV3(opts) {
  const raw = await readFile3(opts.genesisSpecPath, "utf8");
  const spec = parseGenesisSpec(raw);
  const auraConfig = deriveAuraConfig(spec, opts.selfAddress);
  const grandpaConfig = deriveGrandpaConfig(spec, opts.selfAddress);
  const genesisHeader = buildGenesisHeader(spec);
  const selfVoteRecord = new SelfVoteRecord(join(opts.dataDir, "self-votes.jsonl"));
  await selfVoteRecord.load();
  const finalityJournal = new FinalityJournal(join(opts.dataDir, "finality.jsonl"));
  await finalityJournal.load();
  let journalQueue = Promise.resolve();
  const wrappedHooks = {
    ...opts.hooks,
    signVote: wrapSignVote(opts.hooks.signVote, selfVoteRecord),
    restorePriorVote: opts.selfAddress ? (round, kind) => selfVoteRecord.getPrior(round, kind, opts.selfAddress) : void 0,
    onFinalize: (commit) => {
      journalQueue = journalQueue.then(() => finalityJournal.append(commit)).then(() => Promise.all([
        selfVoteRecord.prune(commit.round),
        finalityJournal.prune(commit.round)
      ]).then(() => void 0)).catch((err) => {
        console.error(`[v3-boot] journal error at round ${commit.round}:`, err);
      });
      opts.hooks.onFinalize?.(commit);
    }
  };
  const engine = new V3Engine({
    auraConfig,
    grandpaConfig,
    genesis: { header: genesisHeader },
    hooks: wrappedHooks,
    // #418: a NAT'd pull-mode validator can never broadcast a block it produces,
    // so self-producing on its Aura slots only forks its local chain off
    // canonical and tangles those fork blocks with the finality certs it imports
    // over HTTP. It stays a pure follower (pull canonical + import finality +
    // sign precommits to earn). Baked (no PULL_MODE) produce as before.
    suppressProduction: process.env.ASENTUM_PULL_MODE === "1"
  });
  return { engine, spec, selfVoteRecord, finalityJournal };
}

// src/v3/rpc.ts
function toHex(bytes) {
  let s = "0x";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
function v3Head(engine) {
  const h = engine.getHead();
  return { number: h.number.toString(), hash: toHex(h.hash) };
}
function v3Finality(engine, journal) {
  const f = engine.getFinalityHead();
  return {
    round: f.round.toString(),
    number: f.number.toString(),
    hash: toHex(f.hash),
    journalSize: journal.size(),
    highestJournaledRound: journal.highestFinalizedRound().toString()
  };
}
function v3Genesis(spec) {
  return {
    version: spec.version,
    chainId: spec.chainId,
    chainName: spec.chainName,
    slot0StartUnixMs: spec.slot0StartUnixMs,
    slotDurationMs: spec.slotDurationMs,
    epochSlots: spec.epochSlots,
    quorumNumerator: spec.quorumNumerator,
    quorumDenominator: spec.quorumDenominator,
    committeeSize: spec.initialCommittee.length,
    systemContracts: spec.systemContracts
  };
}
function v3Slashing(engine) {
  const s = engine.getSlashingStats();
  return {
    headersHeld: s.headersHeld,
    votesHeld: s.votesHeld,
    auraEmits: s.auraEmits,
    grandpaEmits: s.grandpaEmits,
    emittedOffenders: s.emittedOffenders
  };
}
function v3Status(boot) {
  const head = v3Head(boot.engine);
  const finality = v3Finality(boot.engine, boot.finalityJournal);
  const slashing = v3Slashing(boot.engine);
  const lag = BigInt(head.number) - BigInt(finality.number);
  return {
    head,
    finality,
    genesis: { chainId: boot.spec.chainId, chainName: boot.spec.chainName },
    slashing,
    finalityLag: lag.toString()
  };
}

// src/v3/health.ts
var DEFAULT_HEAD_STALL = 3;
var DEFAULT_FINALITY_STALL = 6;
var DEFAULT_MAX_LAG = 32n;
var HealthProbe = class {
  engine;
  headStallLimit;
  finalityStallLimit;
  maxLagBlocks;
  lastHead = null;
  lastFinality = null;
  headStallCount = 0;
  finalityStallCount = 0;
  constructor(engine, opts) {
    this.engine = engine;
    this.headStallLimit = opts.headStallSamples ?? DEFAULT_HEAD_STALL;
    this.finalityStallLimit = opts.finalityStallSamples ?? DEFAULT_FINALITY_STALL;
    this.maxLagBlocks = BigInt(opts.maxFinalityLagBlocks ?? Number(DEFAULT_MAX_LAG));
  }
  /**
   * Take a sample. Advances internal state. Returns the current health
   * snapshot including reasons for any unhealthy dimensions.
   *
   * ok = all three dimensions healthy. On the first call, headAdvancing
   * and finalityAdvancing are true by definition (no prior sample to
   * compare against). Only the lag check runs on sample #1.
   */
  sample() {
    const head = this.engine.getHead();
    const fin = this.engine.getFinalityHead();
    const reasons = [];
    let headAdvancing;
    if (this.lastHead === null) {
      headAdvancing = true;
      this.headStallCount = 0;
    } else if (head.number > this.lastHead) {
      headAdvancing = true;
      this.headStallCount = 0;
    } else {
      this.headStallCount += 1;
      headAdvancing = this.headStallCount < this.headStallLimit;
      if (!headAdvancing) {
        reasons.push(
          `head-stalled: number=${head.number} unchanged for ${this.headStallCount} samples (limit=${this.headStallLimit})`
        );
      }
    }
    let finalityAdvancing;
    if (this.lastFinality === null) {
      finalityAdvancing = true;
      this.finalityStallCount = 0;
    } else if (fin.number > this.lastFinality) {
      finalityAdvancing = true;
      this.finalityStallCount = 0;
    } else {
      this.finalityStallCount += 1;
      finalityAdvancing = this.finalityStallCount < this.finalityStallLimit;
      if (!finalityAdvancing) {
        reasons.push(
          `finality-stalled: number=${fin.number} unchanged for ${this.finalityStallCount} samples (limit=${this.finalityStallLimit})`
        );
      }
    }
    const lag = head.number - fin.number;
    const finalityLagBounded = lag <= this.maxLagBlocks;
    if (!finalityLagBounded) {
      reasons.push(
        `finality-lag-exceeded: head=${head.number} finality=${fin.number} lag=${lag} > ${this.maxLagBlocks}`
      );
    }
    this.lastHead = head.number;
    this.lastFinality = fin.number;
    return {
      ok: headAdvancing && finalityAdvancing && finalityLagBounded,
      headAdvancing,
      finalityAdvancing,
      finalityLagBounded,
      headNumber: head.number.toString(),
      finalityNumber: fin.number.toString(),
      finalityLagBlocks: lag.toString(),
      headStallCount: this.headStallCount,
      finalityStallCount: this.finalityStallCount,
      reasons
    };
  }
  /**
   * Reset stall counters. Useful after a genuine restart / snapshot
   * import when the probe should not immediately trip on the head
   * appearing to "not move" because the process has just started.
   */
  reset() {
    this.lastHead = null;
    this.lastFinality = null;
    this.headStallCount = 0;
    this.finalityStallCount = 0;
  }
};

// src/v3/slash-tx.ts
import { AuraBlockHeaderSchema as AuraBlockHeaderSchema2 } from "@asentum/types";
function deserializeSignedVote(bytes) {
  if (bytes.length < 69) {
    throw new Error(`deserializeSignedVote: ${bytes.length} < 69 bytes`);
  }
  if (bytes.length === 69) {
    throw new Error("deserializeSignedVote: 69 bytes = prefix only, missing signature payload");
  }
  const round = readUint64BE(bytes, 0);
  const kindByte = bytes[8];
  const kind = kindByte === 0 ? "prevote" : "precommit";
  const hash = bytes.slice(9, 41);
  const number = readUint64BE(bytes, 41);
  const voter = bytes.slice(49, 69);
  const signature = bytes.slice(69);
  return {
    body: { round, kind, target: { hash, number }, voter },
    signature
  };
}
function readUint64BE(buf, offset) {
  let value = 0n;
  for (let i = 0; i < 8; i++) {
    value = value << 8n | BigInt(buf[offset + i] ?? 0);
  }
  return value;
}

// src/v3/wire.ts
import { AuraBlockSchema } from "@asentum/types";
import { AuraBlockHeaderSchema as AuraBlockHeaderSchema3 } from "@asentum/types";
var V3_MSG_BLOCK = 1;
var V3_MSG_VOTE = 2;
var V3_MSG_BLOCK_WITH_BODY = 3;
function encodeBlockMessage(block) {
  const payload = AuraBlockSchema.serialize(block);
  const out = new Uint8Array(1 + payload.length);
  out[0] = V3_MSG_BLOCK;
  out.set(payload, 1);
  return out;
}
function encodeVoteMessage(vote) {
  const payload = serializeVote(vote);
  const out = new Uint8Array(1 + payload.length);
  out[0] = V3_MSG_VOTE;
  out.set(payload, 1);
  return out;
}
function decodeMessage(bytes) {
  if (bytes.length < 1) {
    throw new Error("v3-wire: empty frame");
  }
  const type = bytes[0];
  const payload = bytes.slice(1);
  if (type === V3_MSG_BLOCK) {
    const block = AuraBlockSchema.deserialize(payload);
    return { type: "block", block };
  }
  if (type === V3_MSG_VOTE) {
    const vote = deserializeSignedVote(payload);
    return { type: "vote", vote };
  }
  if (type === V3_MSG_BLOCK_WITH_BODY) {
    if (payload.length < 4) {
      throw new Error("v3-wire: BLOCK_WITH_BODY frame missing length prefix");
    }
    const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
    const headerLen = dv.getUint32(0, true);
    if (payload.length < 4 + headerLen) {
      throw new Error(`v3-wire: BLOCK_WITH_BODY truncated (need ${4 + headerLen}, got ${payload.length})`);
    }
    const headerBytes = payload.slice(4, 4 + headerLen);
    const bodyBytes = payload.slice(4 + headerLen);
    const header = AuraBlockHeaderSchema3.deserialize(headerBytes);
    return { type: "block-with-body", header, bodyBytes };
  }
  throw new Error(`v3-wire: unknown message type 0x${type?.toString(16)}`);
}
function serializeVote(vote) {
  const buf = new Uint8Array(69 + vote.signature.length);
  writeUint64BE(buf, 0, vote.body.round);
  buf[8] = vote.body.kind === "prevote" ? 0 : 1;
  buf.set(vote.body.target.hash, 9);
  writeUint64BE(buf, 41, vote.body.target.number);
  buf.set(vote.body.voter, 49);
  buf.set(vote.signature, 69);
  return buf;
}
function writeUint64BE(buf, offset, value) {
  for (let i = 7; i >= 0; i--) {
    buf[offset + i] = Number(value & 0xffn);
    value >>= 8n;
  }
}

// src/net/ant/swarm.ts
import { createServer } from "net";

// src/net/ant/peer.ts
import { createConnection } from "net";

// src/net/ant/wire.ts
var MAX_FRAME_BYTES = 16 * 1024 * 1024;
var LENGTH_PREFIX_BYTES = 4;
var MsgType = {
  // Control
  HELLO: 0,
  PING: 1,
  PONG: 2,
  // Consensus + chain
  BLOCK: 16,
  TRANSACTION: 17,
  VOTE: 18,
  PROPOSAL: 19,
  // Sync
  SYNC_REQUEST: 32,
  SYNC_RESPONSE: 33
};
var VALID_MSG_TYPES = new Set(Object.values(MsgType));
function isValidMsgType(byte) {
  return VALID_MSG_TYPES.has(byte);
}
function encodeFrame(type, payload) {
  const bodyLength = 1 + payload.length;
  if (bodyLength > MAX_FRAME_BYTES) {
    throw new Error(
      `ANT frame too large: ${bodyLength} bytes (max ${MAX_FRAME_BYTES})`
    );
  }
  const out = new Uint8Array(LENGTH_PREFIX_BYTES + bodyLength);
  const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
  view.setUint32(0, bodyLength, false);
  out[LENGTH_PREFIX_BYTES] = type;
  out.set(payload, LENGTH_PREFIX_BYTES + 1);
  return out;
}
var FrameDecoder = class {
  buffer = new Uint8Array(0);
  /**
   * Append `chunk` to the internal buffer and return all complete frames
   * that can be extracted. Partial trailing bytes remain buffered for
   * the next feed() call.
   *
   * Throws if a length prefix announces a frame larger than
   * MAX_FRAME_BYTES — the connection should be closed.
   */
  feed(chunk) {
    if (this.buffer.length === 0) {
      this.buffer = chunk;
    } else {
      const next = new Uint8Array(this.buffer.length + chunk.length);
      next.set(this.buffer, 0);
      next.set(chunk, this.buffer.length);
      this.buffer = next;
    }
    const frames = [];
    let offset = 0;
    while (this.buffer.length - offset >= LENGTH_PREFIX_BYTES) {
      const view = new DataView(
        this.buffer.buffer,
        this.buffer.byteOffset + offset,
        LENGTH_PREFIX_BYTES
      );
      const bodyLength = view.getUint32(0, false);
      if (bodyLength === 0) {
        throw new Error("ANT frame: zero-length body");
      }
      if (bodyLength > MAX_FRAME_BYTES) {
        throw new Error(
          `ANT frame too large: ${bodyLength} bytes (max ${MAX_FRAME_BYTES})`
        );
      }
      if (this.buffer.length - offset < LENGTH_PREFIX_BYTES + bodyLength) {
        break;
      }
      const typeByte = this.buffer[offset + LENGTH_PREFIX_BYTES];
      if (!isValidMsgType(typeByte)) {
        throw new Error(`ANT frame: unknown type byte 0x${typeByte.toString(16)}`);
      }
      const payloadStart = offset + LENGTH_PREFIX_BYTES + 1;
      const payloadEnd = offset + LENGTH_PREFIX_BYTES + bodyLength;
      const payload = new Uint8Array(payloadEnd - payloadStart);
      payload.set(this.buffer.subarray(payloadStart, payloadEnd));
      frames.push({ type: typeByte, payload });
      offset = payloadEnd;
    }
    if (offset > 0) {
      this.buffer = this.buffer.length === offset ? new Uint8Array(0) : this.buffer.subarray(offset).slice();
    }
    return frames;
  }
  /** True if no partial frame is currently held. Test-only convenience. */
  isClean() {
    return this.buffer.length === 0;
  }
  /** Reset decoder state — call on connection close so a new connection starts clean. */
  reset() {
    this.buffer = new Uint8Array(0);
  }
};

// src/net/ant/peer.ts
var DEFAULT_QUEUE_LIMIT_BYTES = 4 * 1024 * 1024;
var BACKOFF_INITIAL_MS = 250;
var BACKOFF_MAX_MS = 4e3;
var STABLE_CONNECTION_MS = 1e4;
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
  18
  // VOTE
]);
var AntPeer = class {
  opts;
  log;
  sendQueueLimitBytes;
  socket = null;
  decoder = new FrameDecoder();
  state = "idle";
  /** Outbound bytes waiting for the kernel send buffer. */
  sendQueue = [];
  sendQueueBytes = 0;
  /** Reconnect bookkeeping (outbound only). */
  reconnectTimer = null;
  currentBackoffMs = BACKOFF_INITIAL_MS;
  /** Fires once an outbound connection has been up STABLE_CONNECTION_MS; only
   * then do we reset the backoff, so connect-then-drop churn keeps backing off. */
  stableTimer = null;
  /** Lifetime stats — useful for /debug surfaces later. */
  stats = {
    messagesIn: 0,
    messagesOut: 0,
    bytesIn: 0,
    bytesOut: 0,
    dropsByOverflow: 0,
    reconnects: 0
  };
  constructor(opts) {
    this.opts = opts;
    this.log = opts.log ?? ((msg) => console.log(msg));
    this.sendQueueLimitBytes = opts.sendQueueLimitBytes ?? DEFAULT_QUEUE_LIMIT_BYTES;
    if (this.role() === "outbound") {
      if (!opts.host || !opts.port) {
        throw new Error("AntPeer outbound requires host + port");
      }
    } else if (!opts.inboundSocket) {
      throw new Error("AntPeer inbound requires inboundSocket");
    }
  }
  role() {
    return this.opts.inboundSocket ? "inbound" : "outbound";
  }
  /** Start the peer. For outbound: dial. For inbound: attach handlers. */
  start() {
    if (this.state !== "idle") return;
    if (this.role() === "inbound") {
      this.adoptSocket(this.opts.inboundSocket);
    } else {
      this.dial();
    }
  }
  /**
   * Stop the peer. Closes the socket, cancels reconnect, no further events.
   * Idempotent.
   */
  stop() {
    if (this.state === "closed") return;
    this.state = "closed";
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
    if (this.stableTimer) {
      clearTimeout(this.stableTimer);
      this.stableTimer = null;
    }
    if (this.socket) {
      this.socket.destroy();
      this.socket = null;
    }
    this.sendQueue = [];
    this.sendQueueBytes = 0;
    this.decoder.reset();
  }
  /**
   * Enqueue a frame for transmission. Returns true if accepted, false
   * if dropped (only possible for droppable types under queue overflow).
   *
   * Caller never blocks. The kernel may take milliseconds to drain a
   * write; we trust Node's `socket.write` returning false to mean
   * "buffer is full, back off" — for now we just enqueue and rely on
   * the kernel + Node stream to push through eventually. The overflow
   * cap is the only hard backstop against an unbounded local queue.
   */
  send(type, payload) {
    if (this.state === "closed") return false;
    const frame = encodeFrame(type, payload);
    if (this.sendQueueBytes + frame.length > this.sendQueueLimitBytes) {
      if (DROPPABLE_TYPES.has(type)) {
        const freed = this.dropOldestDroppable();
        if (freed === 0) {
          this.stats.dropsByOverflow++;
          return false;
        }
      } else {
        this.stats.dropsByOverflow++;
        return false;
      }
    }
    this.sendQueue.push(frame);
    this.sendQueueBytes += frame.length;
    this.stats.messagesOut++;
    this.stats.bytesOut += frame.length;
    if (this.state === "connected" && this.socket) {
      this.flushQueue();
    }
    return true;
  }
  /** Drop the oldest droppable frame in the queue. Returns bytes freed. */
  dropOldestDroppable() {
    for (let i = 0; i < this.sendQueue.length; i++) {
      const frame = this.sendQueue[i];
      const typeByte = frame[4];
      if (DROPPABLE_TYPES.has(typeByte)) {
        this.sendQueue.splice(i, 1);
        this.sendQueueBytes -= frame.length;
        this.stats.dropsByOverflow++;
        return frame.length;
      }
    }
    return 0;
  }
  /** Push as much of the send queue as the socket will take. */
  flushQueue() {
    if (!this.socket || this.socket.destroyed) return;
    while (this.sendQueue.length > 0) {
      const frame = this.sendQueue[0];
      const ok = this.socket.write(frame);
      this.sendQueue.shift();
      this.sendQueueBytes -= frame.length;
      if (!ok) {
        break;
      }
    }
  }
  dial() {
    if (this.state === "closed") return;
    this.state = "dialing";
    const sock = createConnection({
      host: this.opts.host,
      port: this.opts.port,
      noDelay: true
      // votes are small + latency-sensitive; turn off Nagle
    });
    sock.on("connect", () => {
      this.adoptSocket(sock);
    });
    sock.on("error", (err) => {
      this.log(`[ant/peer ${this.opts.label}] dial error: ${err.message}`);
    });
    sock.on("close", () => {
      if (this.state === "closed") return;
      this.handleDisconnect();
    });
    this.attachDataHandlers(sock);
  }
  adoptSocket(sock) {
    this.socket = sock;
    this.state = "connected";
    sock.setNoDelay(true);
    sock.on("error", (err) => {
      this.log(`[ant/peer ${this.opts.label}] socket error: ${err.message}`);
    });
    if (this.role() === "inbound") {
      this.attachDataHandlers(sock);
      sock.on("close", () => {
        if (this.state === "closed") return;
        this.handleDisconnect();
      });
    }
    sock.on("drain", () => this.flushQueue());
    if (this.role() === "outbound") {
      if (this.stableTimer) clearTimeout(this.stableTimer);
      this.stableTimer = setTimeout(() => {
        this.stableTimer = null;
        this.currentBackoffMs = BACKOFF_INITIAL_MS;
      }, STABLE_CONNECTION_MS);
    }
    this.opts.onConnect?.();
    this.flushQueue();
  }
  attachDataHandlers(sock) {
    sock.on("data", (chunk) => {
      this.stats.bytesIn += chunk.length;
      try {
        const frames = this.decoder.feed(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
        for (const frame of frames) {
          this.stats.messagesIn++;
          this.opts.onMessage?.(frame);
        }
      } catch (err) {
        this.log(`[ant/peer ${this.opts.label}] frame error: ${err.message}`);
        sock.destroy();
      }
    });
  }
  handleDisconnect(err) {
    this.socket = null;
    this.decoder.reset();
    if (this.stableTimer) {
      clearTimeout(this.stableTimer);
      this.stableTimer = null;
    }
    const wasConnected = this.state === "connected";
    this.state = "idle";
    this.opts.onDisconnect?.(err);
    if (this.role() === "outbound" && this.state !== "closed") {
      this.stats.reconnects++;
      this.scheduleReconnect(wasConnected);
    }
  }
  scheduleReconnect(_wasConnected) {
    if (this.reconnectTimer) return;
    const delay = this.currentBackoffMs;
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      if (this.state === "closed") return;
      this.dial();
    }, delay);
    this.currentBackoffMs = Math.min(this.currentBackoffMs * 2, BACKOFF_MAX_MS);
  }
  /** Test/observability helper — currently connected? */
  isConnected() {
    return this.state === "connected";
  }
};

// src/net/ant/swarm.ts
var DEDUP_WINDOW_MS = (() => {
  const override = Number(process.env.ASENTUM_ANT_DEDUP_WINDOW_MS ?? "");
  if (Number.isFinite(override) && override >= 2e3) return override;
  const blockTime = Number(process.env.ASENTUM_BLOCK_TIME ?? "") || 3e3;
  return Math.max(2e3, Math.floor(blockTime * 1.2));
})();
function fingerprintPayload(payload) {
  const len = payload.length;
  if (len < 32) {
    return Buffer.from(payload).toString("hex");
  }
  const buf = new Uint8Array(32);
  buf.set(payload.subarray(0, 8), 0);
  buf.set(payload.subarray(len >> 2, (len >> 2) + 8), 8);
  buf.set(payload.subarray(len >> 1, (len >> 1) + 8), 16);
  buf.set(payload.subarray(len - 8, len), 24);
  return Buffer.from(buf).toString("hex");
}
var AntSwarm = class {
  opts;
  log;
  server = null;
  outboundPeers = /* @__PURE__ */ new Map();
  // label → peer
  /**
   * label → the endpoint that peer is actually dialing. Kept alongside
   * outboundPeers so addDialTarget can detect that a committee member's
   * on-chain endpoint CHANGED (updateBroadcastUrl/updateAntAddr, or a new
   * epoch's frozen record) and re-dial the new one. Without it, "idempotent
   * by label" pinned the FIRST endpoint ever resolved for an address for the
   * whole process lifetime — the node kept dialing a stale IP under the
   * member's label (prod 2026-08-04: hilsboro's label dialing another
   * validator's box, ECONNREFUSED forever).
   */
  outboundEndpoints = /* @__PURE__ */ new Map();
  inboundPeers = /* @__PURE__ */ new Set();
  /**
   * Chain-driven inbound allowlist (#424 transport). When set, only inbound
   * dials from these hosts are accepted; everyone else is dropped at accept
   * time. This replaces the static UFW IP firewall with a decentralized
   * allowlist derived from the on-chain committee (each member's antAddr host),
   * so a bonded external can join the mesh without anyone editing a firewall.
   * null = accept all (backward-compatible default; the OS firewall still
   * applies underneath). Populated by node.ts from committee state.
   */
  allowedInboundHosts = null;
  started = false;
  closed = false;
  // Devnet-only: simulate cross-DC one-way link latency by delaying outbound
  // frame delivery. ASENTUM_ANT_TX_DELAY_MS is the base delay, JITTER adds a
  // uniform random 0..jitter on top per frame. Both default 0 (no delay), so
  // prod transport is untouched. Used by the cross-DC soak rig to surface
  // GRANDPA round drift that localhost speed hides.
  txDelayMs = Number(process.env.ASENTUM_ANT_TX_DELAY_MS ?? "0") || 0;
  txJitterMs = Number(process.env.ASENTUM_ANT_TX_JITTER_MS ?? "0") || 0;
  /**
   * Two-bucket dedup ring for VOTE + PROPOSAL frames. The full-mesh
   * topology means the same vote arrives once on the outbound conn and
   * once on the inbound conn from every peer, then again whenever any
   * receiver re-broadcasts. Without dedup the engine spends its budget
   * verifying redundant signatures and never makes consensus progress.
   * Bucket rotation gives a bounded window without per-entry timers.
   */
  dedupCurrent = /* @__PURE__ */ new Set();
  dedupPrevious = /* @__PURE__ */ new Set();
  dedupRotateAtMs = Date.now() + DEDUP_WINDOW_MS;
  dedupHits = 0;
  dedupMisses = 0;
  constructor(opts) {
    this.opts = opts;
    this.log = opts.log ?? ((msg) => console.log(msg));
  }
  /** Bind the listener + start dialing all configured peers. */
  async start() {
    if (this.started) return;
    this.started = true;
    this.server = createServer((sock) => this.handleInbound(sock));
    await new Promise((resolve, reject) => {
      const onError = (err) => {
        this.server.off("error", onError);
        reject(err);
      };
      this.server.once("error", onError);
      this.server.listen(this.opts.listenPort, this.opts.listenHost ?? "0.0.0.0", () => {
        this.server.off("error", onError);
        this.log(`[ant/swarm] listening on ${this.opts.listenHost ?? "0.0.0.0"}:${this.opts.listenPort}`);
        resolve();
      });
    });
    for (const target of this.opts.dialTargets) {
      this.startOutboundPeer(target);
    }
  }
  /** Tear everything down. Idempotent. */
  async stop() {
    if (this.closed) return;
    this.closed = true;
    for (const peer of this.outboundPeers.values()) peer.stop();
    this.outboundPeers.clear();
    for (const peer of this.inboundPeers) peer.stop();
    this.inboundPeers.clear();
    if (this.server) {
      await new Promise((res) => this.server.close(() => res()));
      this.server = null;
    }
  }
  /**
   * Broadcast a frame to every currently-connected peer. Backpressure
   * is per-peer — slow peers drop droppable frames locally, fast peers
   * get the whole stream. Returns the number of peers the frame was
   * (successfully) handed to.
   */
  broadcast(type, payload) {
    const peers = [...this.outboundPeers.values(), ...this.inboundPeers];
    if (this.txDelayMs > 0 || this.txJitterMs > 0) {
      for (const peer of peers) {
        const delay = this.txDelayMs + (this.txJitterMs > 0 ? Math.floor(Math.random() * this.txJitterMs) : 0);
        setTimeout(() => {
          if (this.closed) return;
          try {
            peer.send(type, payload);
          } catch {
          }
        }, delay);
      }
      return peers.length;
    }
    let delivered = 0;
    for (const peer of peers) {
      if (peer.send(type, payload)) delivered++;
    }
    return delivered;
  }
  /** Stats snapshot for /debug + telemetry. */
  snapshot() {
    return {
      listenPort: this.opts.listenPort,
      outbound: Array.from(this.outboundPeers.entries()).map(([label, peer]) => ({
        label,
        connected: peer.isConnected(),
        stats: peer.stats
      })),
      inboundCount: this.inboundPeers.size
    };
  }
  /**
   * Set the inbound allowlist to exactly `hosts` (committee-member ANT hosts),
   * or pass null to accept all. Called by node.ts when the committee changes.
   * Existing inbound connections are not torn down here; they age out on their
   * own, and the next committee sync re-derives the set.
   */
  setAllowedInboundHosts(hosts) {
    this.allowedInboundHosts = hosts;
  }
  /**
   * Start dialing + maintaining an outbound peer at runtime (a committee member
   * discovered from chain, #424). Idempotent by label: a target already dialed
   * is left alone. No-op after stop().
   */
  addDialTarget(target) {
    if (this.closed || !this.started) return;
    const existing = this.outboundEndpoints.get(target.label);
    if (this.outboundPeers.has(target.label)) {
      if (existing && existing.host === target.host && existing.port === target.port) {
        return;
      }
      this.log(
        `[ant/swarm] dial target ${target.label} endpoint changed ${existing ? `${existing.host}:${existing.port}` : "?"} -> ${target.host}:${target.port}, re-dialing`
      );
      this.removeDialTarget(target.label);
    }
    this.startOutboundPeer(target);
    this.log(`[ant/swarm] added dial target ${target.label} (${target.host}:${target.port})`);
  }
  /** Stop + drop an outbound peer (a member that left the committee). Idempotent. */
  removeDialTarget(label) {
    const peer = this.outboundPeers.get(label);
    if (!peer) return;
    peer.stop();
    this.outboundPeers.delete(label);
    this.outboundEndpoints.delete(label);
    this.log(`[ant/swarm] removed dial target ${label}`);
  }
  /** Labels of the outbound peers currently maintained. */
  dialTargetLabels() {
    return Array.from(this.outboundPeers.keys());
  }
  startOutboundPeer(target) {
    const peer = new AntPeer({
      label: target.label,
      host: target.host,
      port: target.port,
      sendQueueLimitBytes: this.opts.sendQueueLimitBytes,
      log: this.log,
      onConnect: () => {
        this.opts.onPeerChange?.(target.label, true, "outbound");
      },
      onDisconnect: () => {
        this.opts.onPeerChange?.(target.label, false, "outbound");
      },
      onMessage: (frame) => this.routeMessage(frame, target.label)
    });
    this.outboundPeers.set(target.label, peer);
    this.outboundEndpoints.set(target.label, { host: target.host, port: target.port });
    peer.start();
  }
  handleInbound(sock) {
    if (this.closed) {
      sock.destroy();
      return;
    }
    if (this.allowedInboundHosts) {
      const host = sock.remoteAddress ?? "";
      const norm = host.startsWith("::ffff:") ? host.slice(7) : host;
      if (!this.allowedInboundHosts.has(norm)) {
        sock.on("error", () => {
        });
        sock.destroy();
        return;
      }
    }
    sock.on("error", () => {
    });
    const remote = `${sock.remoteAddress ?? "?"}:${sock.remotePort ?? 0}`;
    const peer = new AntPeer({
      label: `inbound:${remote}`,
      inboundSocket: sock,
      sendQueueLimitBytes: this.opts.sendQueueLimitBytes,
      log: this.log,
      onConnect: () => {
        this.opts.onPeerChange?.(remote, true, "inbound");
      },
      onDisconnect: () => {
        this.inboundPeers.delete(peer);
        this.opts.onPeerChange?.(remote, false, "inbound");
      },
      onMessage: (frame) => this.routeMessage(frame, `inbound:${remote}`)
    });
    this.inboundPeers.add(peer);
    peer.start();
  }
  routeMessage(frame, fromLabel) {
    const handler = this.opts.onMessage?.[frame.type];
    if (!handler) {
      return;
    }
    if (frame.type === MsgType.VOTE || frame.type === MsgType.PROPOSAL) {
      if (this.isDuplicate(frame.type, frame.payload)) {
        return;
      }
    }
    try {
      handler(frame, fromLabel);
    } catch (err) {
      this.log(`[ant/swarm] handler for type=0x${frame.type.toString(16)} threw: ${err.message}`);
    }
  }
  isDuplicate(type, payload) {
    const now = Date.now();
    if (now >= this.dedupRotateAtMs) {
      const drained = this.dedupPrevious;
      drained.clear();
      this.dedupPrevious = this.dedupCurrent;
      this.dedupCurrent = drained;
      this.dedupRotateAtMs = now + DEDUP_WINDOW_MS;
    }
    const key6 = type.toString(16) + ":" + fingerprintPayload(payload);
    if (this.dedupCurrent.has(key6) || this.dedupPrevious.has(key6)) {
      this.dedupHits++;
      return true;
    }
    this.dedupCurrent.add(key6);
    this.dedupMisses++;
    return false;
  }
  /** Test-only / telemetry accessor. */
  get dedupStats() {
    return {
      hits: this.dedupHits,
      misses: this.dedupMisses,
      currentSize: this.dedupCurrent.size,
      previousSize: this.dedupPrevious.size
    };
  }
};

// src/v3/bin/v3-devnet.ts
var ZERO32 = new Uint8Array(32);
function requireEnv(name) {
  const v = process.env[name];
  if (!v) {
    console.error(`[v3-devnet] missing required env var: ${name}`);
    process.exit(2);
  }
  return v;
}
function parsePeers(raw) {
  if (!raw) return [];
  return raw.split(",").map((s) => s.trim()).filter(Boolean).map((entry) => {
    const idx = entry.lastIndexOf(":");
    if (idx < 0) {
      console.error(`[v3-devnet] bad peer entry "${entry}" \u2014 expected host:port`);
      process.exit(2);
    }
    return {
      host: entry.slice(0, idx),
      port: Number.parseInt(entry.slice(idx + 1), 10),
      label: entry
    };
  });
}
async function fileExists(p) {
  try {
    await access(p);
    return true;
  } catch {
    return false;
  }
}
async function loadOrCreateKey(path) {
  if (await fileExists(path)) {
    const seed2 = new Uint8Array(await readFile4(path));
    if (seed2.length !== 32) {
      console.error(`[v3-devnet] key file ${path} must be exactly 32 bytes, got ${seed2.length}`);
      process.exit(2);
    }
    return generateKeypair(seed2);
  }
  await mkdir3(dirname3(path), { recursive: true });
  const seed = new Uint8Array(randomBytes(32));
  await writeFile3(path, seed, { mode: 384 });
  console.log(`[v3-devnet] generated new validator seed at ${path}`);
  return generateKeypair(seed);
}
function hexOf(bytes, take = bytes.length) {
  let s = "0x";
  for (let i = 0; i < Math.min(take, bytes.length); i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
async function main() {
  const specPath = requireEnv("ASENTUM_V3_SPEC");
  const keyPath = requireEnv("ASENTUM_V3_KEY");
  const dataDir = requireEnv("ASENTUM_V3_DATA");
  const antPort = Number.parseInt(process.env.ASENTUM_V3_ANT_PORT ?? "19000", 10);
  const httpPort = Number.parseInt(process.env.ASENTUM_V3_HTTP_PORT ?? "19080", 10);
  const antPeers = parsePeers(process.env.ASENTUM_V3_ANT_PEERS);
  const byzantineMode = process.env.ASENTUM_V3_BYZANTINE ?? "";
  const keypair = await loadOrCreateKey(keyPath);
  const selfAddress = addressBytesFromPublicKey(keypair.publicKey);
  const selfBech32 = bech32AddressFromPublicKey(keypair.publicKey);
  console.log(`[v3-devnet] self address: ${selfBech32}`);
  console.log(`[v3-devnet] self hex:     ${hexOf(selfAddress)}`);
  console.log(`[v3-devnet] spec:         ${specPath}`);
  console.log(`[v3-devnet] data dir:     ${dataDir}`);
  console.log(`[v3-devnet] ANT listen:   :${antPort}`);
  console.log(`[v3-devnet] ANT peers:    ${antPeers.map((p) => p.label).join(", ") || "(none)"}`);
  console.log(`[v3-devnet] HTTP listen:  :${httpPort}`);
  let boot;
  let swarm = null;
  const stubbedAssemble = async (_ctx) => ({
    stateRoot: ZERO32,
    transactionsRoot: ZERO32,
    receiptsRoot: ZERO32,
    gasUsed: 0n,
    gasLimit: 30000000n,
    baseFeePerGas: 0n,
    bodyBytes: new Uint8Array(0)
  });
  boot = await bootV3({
    genesisSpecPath: specPath,
    dataDir,
    selfAddress,
    hooks: {
      assembleBlockBody: stubbedAssemble,
      applyBlock: async () => true,
      signVote: async (body) => {
        const msg = serializeVoteBody(body);
        return sign(msg, keypair.secretKey);
      },
      broadcastBlock: (block) => {
        if (!swarm) return;
        const frame = encodeBlockMessage({ header: block.header, transactions: [] });
        const n = swarm.broadcast(MsgType.BLOCK, frame);
        console.log(`[v3-devnet] produced h=${block.header.number} slot=${block.header.slot} \u2192 ${n} peers`);
        if (byzantineMode === "equivocate-aura") {
          const evilHeader = { ...block.header, stateRoot: new Uint8Array(32).fill(238) };
          const evilFrame = encodeBlockMessage({ header: evilHeader, transactions: [] });
          swarm.broadcast(MsgType.BLOCK, evilFrame);
          console.log(`[v3-devnet] BYZANTINE: broadcast conflicting block for slot ${block.header.slot}`);
        }
      },
      broadcastVote: (vote) => {
        if (!swarm) return;
        const frame = encodeVoteMessage(vote);
        swarm.broadcast(MsgType.VOTE, frame);
        if (byzantineMode === "equivocate-grandpa" && vote.body.kind === "precommit") {
          const evilBody = {
            round: vote.body.round,
            kind: "precommit",
            target: {
              hash: new Uint8Array(32).fill(238),
              number: vote.body.target.number
            },
            voter: vote.body.voter
          };
          const evilSig = sign(serializeVoteBody(evilBody), keypair.secretKey);
          const evilFrame = encodeVoteMessage({ body: evilBody, signature: evilSig });
          swarm.broadcast(MsgType.VOTE, evilFrame);
          console.log(`[v3-devnet] BYZANTINE: broadcast conflicting precommit for round ${vote.body.round}`);
        }
      },
      submitSlashExhibit: (exhibit) => {
        const ev = exhibit.evidence;
        const offender = hexOf(ev.offender);
        const detail = exhibit.source === "aura" ? `aura-equivocation slot=${ev.slot}` : ev.kind === "equivocation" ? `grandpa-equivocation round=${ev.round} kind=${ev.voteKind}` : `grandpa-surround`;
        console.warn(`[v3-devnet] SLASH exhibit \u2014 ${detail} offender=${offender}`);
      },
      onFinalize: (commit) => {
        console.log(
          `[v3-devnet] FINALIZED round=${commit.round} block=${commit.target.number} hash=${hexOf(commit.target.hash, 6)}`
        );
      }
    }
  });
  const inCommittee = boot.spec.initialCommittee.some(
    (v) => v.address_hex.toLowerCase() === hexOf(selfAddress)
  );
  if (!inCommittee) {
    console.warn(
      `[v3-devnet] WARNING: self address ${hexOf(selfAddress)} is NOT in initialCommittee \u2014 this node will never lead or vote`
    );
  } else {
    console.log(`[v3-devnet] self address is in initialCommittee \u2014 will produce + vote`);
  }
  swarm = new AntSwarm({
    listenPort: antPort,
    listenHost: "0.0.0.0",
    dialTargets: antPeers,
    sendQueueLimitBytes: 4 * 1024 * 1024,
    log: (m) => console.log(`[ant] ${m}`),
    onMessage: {
      [MsgType.BLOCK]: (frame) => {
        try {
          const msg = decodeMessage(frame.payload);
          if (msg.type === "block") {
            void boot.engine.onPeerBlock({
              header: msg.block.header,
              bodyBytes: new Uint8Array(0)
            });
          }
        } catch (err) {
          console.warn(`[v3-devnet] ignored bad BLOCK frame: ${err.message}`);
        }
      },
      [MsgType.VOTE]: (frame) => {
        try {
          const msg = decodeMessage(frame.payload);
          if (msg.type === "vote") boot.engine.onPeerVote(msg.vote);
        } catch (err) {
          console.warn(`[v3-devnet] ignored bad VOTE frame: ${err.message}`);
        }
      }
    },
    onPeerChange: (label, connected) => {
      console.log(`[v3-devnet] peer ${label} ${connected ? "UP" : "DOWN"}`);
    }
  });
  await swarm.start();
  boot.engine.start();
  console.log(`[v3-devnet] engine started, chainId=${boot.spec.chainId}`);
  const probe = new HealthProbe(boot.engine, {
    samplePeriodMs: boot.spec.slotDurationMs
  });
  const healthTick = setInterval(() => {
    probe.sample();
  }, boot.spec.slotDurationMs);
  const server = createServer2((req, res) => {
    try {
      const url = req.url ?? "/";
      let body = null;
      if (url === "/v3/head") body = v3Head(boot.engine);
      else if (url === "/v3/finality") body = v3Finality(boot.engine, boot.finalityJournal);
      else if (url === "/v3/genesis") body = v3Genesis(boot.spec);
      else if (url === "/v3/slashing") body = v3Slashing(boot.engine);
      else if (url === "/v3/status") body = v3Status(boot);
      else if (url === "/v3/health") body = probe.sample();
      if (!body) {
        res.writeHead(404, { "content-type": "text/plain" });
        res.end("not found");
        return;
      }
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify(body));
    } catch (err) {
      res.writeHead(500, { "content-type": "text/plain" });
      res.end(`error: ${err.message}`);
    }
  });
  server.listen(httpPort, () => {
    console.log(`[v3-devnet] HTTP ready on :${httpPort}`);
  });
  const shutdown = async (signal) => {
    console.log(`[v3-devnet] ${signal} received, shutting down`);
    clearInterval(healthTick);
    server.close();
    boot.engine.stop();
    if (swarm) await swarm.stop();
    process.exit(0);
  };
  process.on("SIGINT", () => void shutdown("SIGINT"));
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
}
function serializeVoteBody(body) {
  const out = new Uint8Array(8 + 1 + 32 + 8 + 20);
  const dv = new DataView(out.buffer);
  dv.setBigUint64(0, body.round, true);
  out[8] = body.kind === "prevote" ? 0 : 1;
  out.set(body.target.hash, 9);
  dv.setBigUint64(41, body.target.number, true);
  out.set(body.voter, 49);
  return out;
}
main().catch((err) => {
  console.error("[v3-devnet] fatal:", err);
  process.exit(1);
});
//# sourceMappingURL=v3-devnet.js.map