var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};

// src/consensus/sign.ts
var sign_exports = {};
__export(sign_exports, {
  buildAndSignVote: () => buildAndSignVote,
  decodeSignedVote: () => decodeSignedVote,
  encodeSignedVote: () => encodeSignedVote,
  verifySignedVote: () => verifySignedVote,
  voteDigest: () => voteDigest,
  voteKindFromByte: () => voteKindFromByte,
  voteKindToByte: () => voteKindToByte
});
import {
  addressBytesFromPublicKey,
  blake3,
  sign as dilithiumSign,
  verify as dilithiumVerify
} from "@asentum/crypto";
import {
  SignedVoteSchema,
  VoteBodySchema,
  VOTE_KIND_PRECOMMIT,
  VOTE_KIND_PREVOTE
} from "@asentum/types";
function voteKindToByte(kind) {
  return kind === "prevote" ? VOTE_KIND_PREVOTE : VOTE_KIND_PRECOMMIT;
}
function voteKindFromByte(byte) {
  if (byte === VOTE_KIND_PREVOTE) return "prevote";
  if (byte === VOTE_KIND_PRECOMMIT) return "precommit";
  throw new Error(`unknown vote kind byte: ${byte}`);
}
function voteDigest(body) {
  return blake3(VoteBodySchema.serialize(body));
}
function buildAndSignVote(opts) {
  if (opts.blockHash.length !== 32) {
    throw new Error(`voteBody: blockHash must be 32 bytes, got ${opts.blockHash.length}`);
  }
  const validator = addressBytesFromPublicKey(opts.keypair.publicKey);
  const body = {
    kind: voteKindToByte(opts.kind),
    chainId: opts.chainId,
    height: opts.height,
    round: opts.round,
    blockHash: opts.blockHash,
    validator
  };
  const digest = voteDigest(body);
  const signature = dilithiumSign(digest, opts.keypair.secretKey);
  return {
    body,
    publicKey: opts.keypair.publicKey,
    signature
  };
}
function verifySignedVote(vote) {
  if (vote.body.blockHash.length !== 32) {
    return { ok: false, reason: "blockHash wrong length" };
  }
  const derived = addressBytesFromPublicKey(vote.publicKey);
  if (!bytesEqual(derived, vote.body.validator)) {
    return { ok: false, reason: "validator address does not match publicKey" };
  }
  const digest = voteDigest(vote.body);
  if (!dilithiumVerify(digest, vote.signature, vote.publicKey)) {
    return { ok: false, reason: "invalid signature" };
  }
  return { ok: true };
}
function encodeSignedVote(vote) {
  return SignedVoteSchema.serialize(vote);
}
function decodeSignedVote(bytes) {
  return SignedVoteSchema.deserialize(bytes);
}
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 init_sign = __esm({
  "src/consensus/sign.ts"() {
    "use strict";
  }
});

// src/v3/genesis-spec.ts
var genesis_spec_exports = {};
__export(genesis_spec_exports, {
  buildGenesisHeader: () => buildGenesisHeader,
  canonicalizeGenesisSpec: () => canonicalizeGenesisSpec,
  deriveAuraConfig: () => deriveAuraConfig,
  deriveGrandpaConfig: () => deriveGrandpaConfig,
  genesisSpecHash: () => genesisSpecHash,
  parseGenesisSpec: () => parseGenesisSpec,
  validateGenesisSpec: () => validateGenesisSpec
});
import { blake3 as blake315 } from "@asentum/crypto";
function hexToBytes7(hex2) {
  const clean = hex2.startsWith("0x") || hex2.startsWith("0X") ? hex2.slice(2) : hex2;
  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 blake315(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) => hexToBytes7(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) => hexToBytes7(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;
}
var init_genesis_spec = __esm({
  "src/v3/genesis-spec.ts"() {
    "use strict";
  }
});

// src/aura/slot.ts
var slot_exports = {};
__export(slot_exports, {
  deterministicTimestamp: () => deterministicTimestamp,
  isBlockOnTime: () => isBlockOnTime,
  slotEndTime: () => slotEndTime,
  slotForTime: () => slotForTime,
  slotStartTime: () => slotStartTime
});
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 slotEndTime(cfg, slot) {
  return slotStartTime(cfg, slot) + cfg.slotDurationMs;
}
function isBlockOnTime(cfg, slot, nowUnixMs) {
  const end = slotEndTime(cfg, slot);
  return nowUnixMs <= end + cfg.graceWindowMs;
}
function deterministicTimestamp(cfg, slot) {
  return BigInt(slotStartTime(cfg, slot));
}
var init_slot = __esm({
  "src/aura/slot.ts"() {
    "use strict";
  }
});

// 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;
}
var init_leader_schedule = __esm({
  "src/aura/leader-schedule.ts"() {
    "use strict";
  }
});

// src/aura/producer.ts
var AuraProducer;
var init_producer = __esm({
  "src/aura/producer.ts"() {
    "use strict";
    init_slot();
    init_leader_schedule();
    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 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;
}
var ForkChoice;
var init_fork_choice = __esm({
  "src/aura/fork-choice.ts"() {
    "use strict";
    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: !bytesEqual6(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
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;
}
function committeeIndex(committee, addr) {
  for (let i = 0; i < committee.length; i++) {
    const c = committee[i];
    if (c && bytesEqual7(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 (!bytesEqual7(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;
}
var SIGNATURE_BYTES;
var init_aggregator = __esm({
  "src/grandpa/aggregator.ts"() {
    "use strict";
    SIGNATURE_BYTES = 3309;
  }
});

// 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 quorumThreshold2(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 = quorumThreshold2(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;
}
var init_round = __esm({
  "src/grandpa/round.ts"() {
    "use strict";
  }
});

// src/grandpa/store.ts
function envCertGraceMs() {
  const n = Number(process.env.ASENTUM_CERT_GRACE_MS ?? "0");
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
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 bytesEqual8(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 DEFAULT_MAX_UNFINALIZED_ROUNDS, AUGMENT_FINALIZED_CERT, AUGMENT_WINDOW_ROUNDS, FinalityStore;
var init_store = __esm({
  "src/grandpa/store.ts"() {
    "use strict";
    init_aggregator();
    init_round();
    DEFAULT_MAX_UNFINALIZED_ROUNDS = 1024;
    AUGMENT_FINALIZED_CERT = process.env.ASENTUM_V3_DYNAMIC_COMMITTEE === "1";
    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;
    })();
    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 || !bytesEqual8(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) => bytesEqual8(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 (bytesEqual8(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 (!bytesEqual8(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 (!bytesEqual8(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 (!bytesEqual8(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;
      }
    };
  }
});

// src/grandpa/voter.ts
function bytesEqual9(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;
}
var GrandpaVoter;
var init_voter = __esm({
  "src/grandpa/voter.ts"() {
    "use strict";
    init_store();
    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 || !bytesEqual9(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 };
      }
    };
  }
});

// src/aura/slashing.ts
function bytesEqual10(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 bytesEqual10(a.parentHash, b.parentHash) && bytesEqual10(a.stateRoot, b.stateRoot) && bytesEqual10(a.transactionsRoot, b.transactionsRoot) && bytesEqual10(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 && bytesEqual10(a.proposer, b.proposer);
}
function detectAuraEquivocation(a, b) {
  if (a.slot !== b.slot) return null;
  if (!bytesEqual10(a.proposer, b.proposer)) return null;
  if (headersIdentical(a, b)) return null;
  return {
    slot: a.slot,
    offender: a.proposer,
    headerA: a,
    headerB: b
  };
}
var init_slashing = __esm({
  "src/aura/slashing.ts"() {
    "use strict";
  }
});

// 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;
var init_header_history = __esm({
  "src/aura/header-history.ts"() {
    "use strict";
    init_slashing();
    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 bytesEqual11(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 (!bytesEqual11(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 (bytesEqual11(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 (!bytesEqual11(a.body.voter, b.body.voter)) return null;
  if (a.body.round === b.body.round) return null;
  if (bytesEqual11(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
  };
}
var init_slashing2 = __esm({
  "src/grandpa/slashing.ts"() {
    "use strict";
  }
});

// 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;
var init_vote_history = __esm({
  "src/grandpa/vote-history.ts"() {
    "use strict";
    init_slashing2();
    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;
var init_slashing_reporter = __esm({
  "src/v3/slashing-reporter.ts"() {
    "use strict";
    init_header_history();
    init_vote_history();
    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
import { blake3 as blake316 } from "@asentum/crypto";
import { AuraBlockHeaderSchema } from "@asentum/types";
var V3Engine;
var init_engine = __esm({
  "src/v3/engine.ts"() {
    "use strict";
    init_producer();
    init_fork_choice();
    init_store();
    init_voter();
    init_slashing_reporter();
    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 = blake316(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 = blake316(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 = blake316(AuraBlockHeaderSchema.serialize(block.header));
        if (this.forkChoice.get(hash)) return;
        const hex2 = this.hexOf(hash);
        if (this.applyInFlight.has(hex2)) return;
        this.applyInFlight.add(hex2);
        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(hex2);
          }
        })();
        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(blake316(AuraBlockHeaderSchema.serialize(block.header)));
        if (list.some((b) => this.hexOf(blake316(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(blake316(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(blake316(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 = blake316(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 ?? blake316(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 as writeFile3, readFile as readFile2, mkdir as mkdir2 } from "fs/promises";
import { dirname } from "path";
function bytesToHex14(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
function hexToBytes8(hex2) {
  const clean = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  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, SelfVoteRecord;
var init_self_vote_record = __esm({
  "src/v3/self-vote-record.ts"() {
    "use strict";
    WouldEquivocateError = class extends Error {
      constructor(round, kind, voter, priorTargetHash, candidateTargetHash) {
        super(
          `self-vote-record: would equivocate at round ${round} ${kind} \u2014 prior target ${bytesToHex14(
            priorTargetHash
          ).slice(0, 12)} vs candidate ${bytesToHex14(candidateTargetHash).slice(0, 12)}`
        );
        this.round = round;
        this.kind = kind;
        this.voter = voter;
        this.priorTargetHash = priorTargetHash;
        this.candidateTargetHash = candidateTargetHash;
      }
      round;
      kind;
      voter;
      priorTargetHash;
      candidateTargetHash;
    };
    SelfVoteRecord = class {
      path;
      byKey = /* @__PURE__ */ new Map();
      loaded = false;
      constructor(path3) {
        this.path = path3;
      }
      /**
       * 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 readFile2(this.path, "utf8");
        } catch (err) {
          if (err.code === "ENOENT") {
            await mkdir2(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 = bytesToHex14(vote.body.voter);
        const key6 = recordKey({
          round: vote.body.round,
          kind: vote.body.kind,
          voter: voterHex
        });
        const targetHex = bytesToHex14(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,
              hexToBytes8(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: bytesToHex14(voter) });
        const rec = this.byKey.get(key6);
        if (!rec) return null;
        return {
          targetHash: hexToBytes8(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: bytesToHex14(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 !== bytesToHex14(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 writeFile3(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 mkdir2(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 writeFile4, readFile as readFile3, mkdir as mkdir3 } from "fs/promises";
import { dirname as dirname2 } from "path";
function bytesToHex15(bytes) {
  let s = "";
  for (let i = 0; i < bytes.length; i++) {
    s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return s;
}
function hexToBytes9(hex2) {
  const clean = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  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: bytesToHex15(c.target.hash),
    target_number: c.target.number.toString(),
    bitmap_hex: bytesToHex15(c.bitmap),
    signatures_hex: bytesToHex15(c.signatures)
  };
}
function deserialize(j) {
  return {
    round: BigInt(j.round),
    target: {
      hash: hexToBytes9(j.target_hash_hex),
      number: BigInt(j.target_number)
    },
    bitmap: hexToBytes9(j.bitmap_hex),
    signatures: hexToBytes9(j.signatures_hex)
  };
}
function bytesEqual12(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 FinalityJournal;
var init_finality_journal = __esm({
  "src/v3/finality-journal.ts"() {
    "use strict";
    FinalityJournal = class {
      path;
      byRound = /* @__PURE__ */ new Map();
      highestRound = 0n;
      loaded = false;
      constructor(path3) {
        this.path = path3;
      }
      /**
       * 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 readFile3(this.path, "utf8");
        } catch (err) {
          if (err.code === "ENOENT") {
            await mkdir3(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 (bytesEqual12(existing.target.hash, commit.target.hash)) return;
          throw new Error(
            `finality-journal: conflicting commit at round ${commit.round}: existing target ${bytesToHex15(existing.target.hash).slice(0, 12)} vs new ${bytesToHex15(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 writeFile4(this.path, lines.length > 0 ? lines.join("\n") + "\n" : "");
      }
      async appendFsync(line) {
        await mkdir3(dirname2(this.path), { recursive: true });
        const fh = await open2(this.path, "a");
        try {
          await fh.write(line);
          await fh.sync();
        } finally {
          await fh.close();
        }
      }
    };
  }
});

// src/v3/boot.ts
import { readFile as readFile4 } from "fs/promises";
import { join as join5 } from "path";
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 readFile4(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(join5(opts.dataDir, "self-votes.jsonl"));
  await selfVoteRecord.load();
  const finalityJournal = new FinalityJournal(join5(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 };
}
var init_boot = __esm({
  "src/v3/boot.ts"() {
    "use strict";
    init_genesis_spec();
    init_engine();
    init_self_vote_record();
    init_finality_journal();
  }
});

// src/v3/genesis-builder.ts
function buildV3Genesis(input) {
  if (input.initialCommittee.length === 0) {
    throw new Error("buildV3Genesis: initialCommittee must be non-empty");
  }
  const numerator = input.quorumNumerator ?? 2;
  const denominator = input.quorumDenominator ?? 3;
  if (numerator <= 0 || denominator <= 0 || numerator >= denominator) {
    throw new Error(
      `buildV3Genesis: quorum must satisfy 0 < ${numerator} < ${denominator}`
    );
  }
  const spec = {
    version: 3,
    chainId: input.chainId,
    chainName: input.chainName,
    slot0StartUnixMs: input.slot0StartUnixMs,
    slotDurationMs: input.slotDurationMs ?? 3e3,
    epochSlots: input.epochSlots ?? 1440,
    quorumNumerator: numerator,
    quorumDenominator: denominator,
    initialCommittee: input.initialCommittee.map((v) => ({
      address_hex: v.address_hex.toLowerCase(),
      stake_wei: v.stake_wei,
      // Carry the transport + identity fields into the written spec so the node
      // seeds real ML-DSA pubkeys (#416 vote verification) and committee members
      // are ANT-discoverable (#424). These are NOT in the canonical hash
      // (canonicalizeGenesisSpec keeps only address+stake), so they never affect
      // genesis-hash agreement across nodes — they are informational seed data.
      ...v.pubKey_hex ? { pubKey_hex: v.pubKey_hex } : {},
      ...v.broadcastUrl ? { broadcastUrl: v.broadcastUrl } : {},
      ...v.antAddr ? { antAddr: v.antAddr } : {}
    })),
    initialBalances: (input.initialBalances ?? []).map((b) => ({
      address_hex: b.address_hex.toLowerCase(),
      balance_wei: b.balance_wei
    })),
    systemContracts: {
      ...V3_SYSTEM_CONTRACTS,
      ...input.systemContracts ?? {}
    }
  };
  validateGenesisSpec(spec);
  return spec;
}
function serializeV3Genesis(spec) {
  return JSON.stringify(spec, null, 2) + "\n";
}
function buildV3GenesisEqualStake(input) {
  const initialCommittee = input.validators.map((address_hex) => ({
    address_hex,
    stake_wei: input.stakePerValidatorWei
  }));
  return buildV3Genesis({ ...input, initialCommittee });
}
var V3_SYSTEM_CONTRACTS;
var init_genesis_builder = __esm({
  "src/v3/genesis-builder.ts"() {
    "use strict";
    init_genesis_spec();
    V3_SYSTEM_CONTRACTS = {
      staking_hex: "0x0000000000000000000000000000000000000001",
      slashing_hex: "0x0000000000000000000000000000000000000002",
      cron_hex: "0x0000000000000000000000000000000000000003",
      vault_hex: "0x0000000000000000000000000000000000000008",
      workerRegistry_hex: "0x0000000000000000000000000000000000000009"
    };
  }
});

// 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()
  };
}
var init_rpc = __esm({
  "src/v3/rpc.ts"() {
    "use strict";
  }
});

// src/v3/health.ts
var DEFAULT_HEAD_STALL, DEFAULT_FINALITY_STALL, DEFAULT_MAX_LAG, HealthProbe;
var init_health = __esm({
  "src/v3/health.ts"() {
    "use strict";
    DEFAULT_HEAD_STALL = 3;
    DEFAULT_FINALITY_STALL = 6;
    DEFAULT_MAX_LAG = 32n;
    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 buildSlashCallArgs(exhibit) {
  if (exhibit.source === "aura") {
    const { evidence: evidence2 } = exhibit;
    return {
      method: "recordSlash",
      offender: evidence2.offender,
      exhibitA: AuraBlockHeaderSchema2.serialize(evidence2.headerA),
      exhibitB: AuraBlockHeaderSchema2.serialize(evidence2.headerB),
      source: "aura"
    };
  }
  const { evidence } = exhibit;
  const voteA = evidence.kind === "equivocation" ? evidence.voteA : evidence.earlier;
  const voteB = evidence.kind === "equivocation" ? evidence.voteB : evidence.later;
  return {
    method: "recordSlash",
    offender: evidence.offender,
    exhibitA: serializeSignedVote(voteA),
    exhibitB: serializeSignedVote(voteB),
    source: "grandpa",
    grandpaKind: evidence.kind
  };
}
function serializeSignedVote(vote) {
  const buf = new Uint8Array(69 + vote.signature.length);
  writeUint64BE2(buf, 0, vote.body.round);
  buf[8] = vote.body.kind === "prevote" ? 0 : 1;
  buf.set(vote.body.target.hash, 9);
  writeUint64BE2(buf, 41, vote.body.target.number);
  buf.set(vote.body.voter, 49);
  buf.set(vote.signature, 69);
  return buf;
}
function writeUint64BE2(buf, offset, value) {
  for (let i = 7; i >= 0; i--) {
    buf[offset + i] = Number(value & 0xffn);
    value >>= 8n;
  }
}
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;
}
var init_slash_tx = __esm({
  "src/v3/slash-tx.ts"() {
    "use strict";
  }
});

// src/v3/wire.ts
import { AuraBlockSchema } from "@asentum/types";
import { AuraBlockHeaderSchema as AuraBlockHeaderSchema3 } from "@asentum/types";
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 encodeBlockWithBodyMessage(header, bodyBytes) {
  const headerBytes = AuraBlockHeaderSchema3.serialize(header);
  const out = new Uint8Array(1 + 4 + headerBytes.length + bodyBytes.length);
  out[0] = V3_MSG_BLOCK_WITH_BODY;
  const dv = new DataView(out.buffer);
  dv.setUint32(1, headerBytes.length, true);
  out.set(headerBytes, 5);
  out.set(bodyBytes, 5 + headerBytes.length);
  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);
  writeUint64BE3(buf, 0, vote.body.round);
  buf[8] = vote.body.kind === "prevote" ? 0 : 1;
  buf.set(vote.body.target.hash, 9);
  writeUint64BE3(buf, 41, vote.body.target.number);
  buf.set(vote.body.voter, 49);
  buf.set(vote.signature, 69);
  return buf;
}
function writeUint64BE3(buf, offset, value) {
  for (let i = 7; i >= 0; i--) {
    buf[offset + i] = Number(value & 0xffn);
    value >>= 8n;
  }
}
var V3_MSG_BLOCK, V3_MSG_VOTE, V3_MSG_BLOCK_WITH_BODY;
var init_wire = __esm({
  "src/v3/wire.ts"() {
    "use strict";
    init_slash_tx();
    V3_MSG_BLOCK = 1;
    V3_MSG_VOTE = 2;
    V3_MSG_BLOCK_WITH_BODY = 3;
  }
});

// src/v3/index.ts
var v3_exports = {};
__export(v3_exports, {
  FinalityJournal: () => FinalityJournal,
  HealthProbe: () => HealthProbe,
  SelfVoteRecord: () => SelfVoteRecord,
  SlashingReporter: () => SlashingReporter,
  V3Engine: () => V3Engine,
  V3_MSG_BLOCK: () => V3_MSG_BLOCK,
  V3_MSG_BLOCK_WITH_BODY: () => V3_MSG_BLOCK_WITH_BODY,
  V3_MSG_VOTE: () => V3_MSG_VOTE,
  V3_SYSTEM_CONTRACTS: () => V3_SYSTEM_CONTRACTS,
  WouldEquivocateError: () => WouldEquivocateError,
  bootV3: () => bootV3,
  buildGenesisHeader: () => buildGenesisHeader,
  buildSlashCallArgs: () => buildSlashCallArgs,
  buildV3Genesis: () => buildV3Genesis,
  buildV3GenesisEqualStake: () => buildV3GenesisEqualStake,
  canonicalizeGenesisSpec: () => canonicalizeGenesisSpec,
  decodeMessage: () => decodeMessage,
  deriveAuraConfig: () => deriveAuraConfig,
  deriveGrandpaConfig: () => deriveGrandpaConfig,
  deserializeSignedVote: () => deserializeSignedVote,
  encodeBlockMessage: () => encodeBlockMessage,
  encodeBlockWithBodyMessage: () => encodeBlockWithBodyMessage,
  encodeVoteMessage: () => encodeVoteMessage,
  genesisSpecHash: () => genesisSpecHash,
  parseGenesisSpec: () => parseGenesisSpec,
  serializeV3Genesis: () => serializeV3Genesis,
  v3Finality: () => v3Finality,
  v3Genesis: () => v3Genesis,
  v3Head: () => v3Head,
  v3Slashing: () => v3Slashing,
  v3Status: () => v3Status,
  validateGenesisSpec: () => validateGenesisSpec
});
var init_v3 = __esm({
  "src/v3/index.ts"() {
    "use strict";
    init_boot();
    init_engine();
    init_genesis_spec();
    init_genesis_builder();
    init_self_vote_record();
    init_finality_journal();
    init_rpc();
    init_health();
    init_slashing_reporter();
    init_slash_tx();
    init_wire();
  }
});

// src/net/proposal-poller.ts
var proposal_poller_exports = {};
__export(proposal_poller_exports, {
  ProposalPoller: () => ProposalPoller
});
function sleep2(ms) {
  return new Promise((res) => setTimeout(res, ms));
}
function hexToBytes12(hex2) {
  const s = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  if (s.length % 2 !== 0) throw new Error(`odd-length hex string (${s.length} chars)`);
  const out = new Uint8Array(s.length / 2);
  for (let i = 0; i < out.length; i++) {
    const byte = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);
    out[i] = byte;
  }
  return out;
}
var ProposalPoller;
var init_proposal_poller = __esm({
  "src/net/proposal-poller.ts"() {
    "use strict";
    ProposalPoller = class _ProposalPoller {
      stopped = false;
      relayIndex = 0;
      /**
       * Task #293: exponential backoff on consecutive fetch failures. If
       * every relay in a row fails (network down, DNS blip, DDoS mitigation),
       * keep retrying but stretch the interval so we're not hammering. Reset
       * on the first successful fetch.
       */
      consecutiveFetchFails = 0;
      static MAX_BACKOFF_MS = 3e4;
      /**
       * Track which (height, round) we already handed off to the engine, so
       * a relay returning the same cached proposal on every tick doesn't
       * make us re-call applyProposalWithBft (which would be a no-op via
       * `tentative.alreadyHave` but still wastes work).
       */
      lastFedKey = "";
      opts;
      constructor(opts) {
        if (opts.relayUrls.length === 0) {
          throw new Error("ProposalPoller requires at least one relayUrl");
        }
        this.opts = {
          relayUrls: opts.relayUrls.map((u) => u.replace(/\/+$/, "")),
          node: opts.node,
          pollIntervalMs: opts.pollIntervalMs ?? Number(process.env.ASENTUM_PULL_PROPOSAL_MS ?? "75"),
          timeoutMs: opts.timeoutMs ?? 1500,
          log: opts.log ?? ((m) => console.log(`[pull-mode] ${m}`))
        };
      }
      /** Run forever until `stop()`. Resolves only on stop. */
      async start() {
        this.opts.log(
          `polling proposals from ${this.opts.relayUrls.length} relay(s) every ${this.opts.pollIntervalMs}ms`
        );
        while (!this.stopped) {
          try {
            await this.tick();
          } catch (err) {
            this.opts.log(`tick failed: ${String(err instanceof Error ? err.message : err)}`);
          }
          let extraMs = 0;
          if (this.consecutiveFetchFails > 0) {
            const factor = Math.min(1.5 ** this.consecutiveFetchFails, 400);
            extraMs = Math.min(
              this.opts.pollIntervalMs * factor,
              _ProposalPoller.MAX_BACKOFF_MS
            );
            if (this.consecutiveFetchFails === this.opts.relayUrls.length * 5) {
              this.opts.log(
                `all ${this.opts.relayUrls.length} relay(s) unreachable, backing off to ${Math.round((this.opts.pollIntervalMs + extraMs) / 1e3)}s intervals`
              );
            }
          }
          await sleep2(this.opts.pollIntervalMs + extraMs);
        }
      }
      /** Idempotent. Call to gracefully exit the polling loop. */
      stop() {
        this.stopped = true;
      }
      async tick() {
        const head = this.opts.node.getChainInfo().height;
        const nextHeight = head + 1n;
        const relay = this.opts.relayUrls[this.relayIndex];
        this.relayIndex = (this.relayIndex + 1) % this.opts.relayUrls.length;
        const url = `${relay}/consensus/proposal?height=${nextHeight.toString()}`;
        let body;
        try {
          const ctrl = new AbortController();
          const t = setTimeout(() => ctrl.abort(), this.opts.timeoutMs);
          try {
            const res = await fetch(url, { signal: ctrl.signal });
            if (!res.ok) {
              this.consecutiveFetchFails = 0;
              return;
            }
            body = await res.json();
            this.consecutiveFetchFails = 0;
          } finally {
            clearTimeout(t);
          }
        } catch {
          this.consecutiveFetchFails++;
          return;
        }
        if (!body.available || !body.rawBlock || !body.height || !body.round) return;
        const key6 = `${body.height}:${body.round}`;
        if (key6 === this.lastFedKey) return;
        this.lastFedKey = key6;
        let rawBytes;
        try {
          rawBytes = hexToBytes12(body.rawBlock);
        } catch (err) {
          this.opts.log(`relay ${relay} returned invalid rawBlock hex: ${String(err)}`);
          return;
        }
        let proposerRound;
        try {
          proposerRound = BigInt(body.round);
        } catch {
          proposerRound = void 0;
        }
        const result = await this.opts.node.applyProposalWithBft(rawBytes, { proposerRound });
        if (result.accepted) {
          this.opts.log(`fed proposal h=${body.height} r=${body.round} from ${relay}`);
        } else {
          this.opts.log(`rejected proposal h=${body.height} r=${body.round}: ${result.reason}`);
        }
      }
    };
  }
});

// src/net/vote-poller.ts
var vote_poller_exports = {};
__export(vote_poller_exports, {
  VotePoller: () => VotePoller
});
import {
  SignedVoteSchema as SignedVoteSchema5
} from "@asentum/types";
function sleep3(ms) {
  return new Promise((res) => setTimeout(res, ms));
}
function hexToBytes13(hex2) {
  const s = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  if (s.length % 2 !== 0) throw new Error(`odd-length hex string (${s.length} chars)`);
  const out = new Uint8Array(s.length / 2);
  for (let i = 0; i < out.length; i++) {
    const byte = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);
    out[i] = byte;
  }
  return out;
}
function bytesToHexLower4(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
var VotePoller;
var init_vote_poller = __esm({
  "src/net/vote-poller.ts"() {
    "use strict";
    VotePoller = class _VotePoller {
      stopped = false;
      relayIndex = 0;
      /** Task #293: exponential backoff on consecutive fetch failures. */
      consecutiveFetchFails = 0;
      static MAX_BACKOFF_MS = 3e4;
      /**
       * Per-(kind, validator) dedup so a relay returning the same vote on
       * every tick doesn't make us re-ingest. Keyed by `${kind}:${h}:${r}:${validatorHex}`.
       * Cleared whenever the engine (h, r) changes, to bound memory.
       */
      ingestedKeys = /* @__PURE__ */ new Set();
      lastSeenHR = "";
      opts;
      constructor(opts) {
        if (opts.relayUrls.length === 0) {
          throw new Error("VotePoller requires at least one relayUrl");
        }
        this.opts = {
          relayUrls: opts.relayUrls.map((u) => u.replace(/\/+$/, "")),
          node: opts.node,
          pollIntervalMs: opts.pollIntervalMs ?? Number(process.env.ASENTUM_PULL_VOTE_MS ?? "50"),
          timeoutMs: opts.timeoutMs ?? 1500,
          log: opts.log ?? ((m) => console.log(`[pull-mode/votes] ${m}`))
        };
      }
      async start() {
        this.opts.log(
          `polling votes from ${this.opts.relayUrls.length} relay(s) every ${this.opts.pollIntervalMs}ms`
        );
        while (!this.stopped) {
          try {
            await this.tick();
          } catch (err) {
            this.opts.log(`tick failed: ${String(err instanceof Error ? err.message : err)}`);
          }
          let extraMs = 0;
          if (this.consecutiveFetchFails > 0) {
            const factor = Math.min(1.5 ** this.consecutiveFetchFails, 400);
            extraMs = Math.min(
              this.opts.pollIntervalMs * factor,
              _VotePoller.MAX_BACKOFF_MS
            );
            if (this.consecutiveFetchFails === this.opts.relayUrls.length * 5) {
              this.opts.log(
                `all ${this.opts.relayUrls.length} relay(s) unreachable, backing off to ${Math.round((this.opts.pollIntervalMs + extraMs) / 1e3)}s intervals`
              );
            }
          }
          await sleep3(this.opts.pollIntervalMs + extraMs);
        }
      }
      stop() {
        this.stopped = true;
      }
      async tick() {
        const state = this.opts.node.getConsensusState();
        let height;
        let round;
        try {
          height = BigInt(state.height);
          round = BigInt(state.round);
        } catch {
          return;
        }
        const hrKey = `${height}:${round}`;
        if (hrKey !== this.lastSeenHR) {
          this.ingestedKeys.clear();
          this.lastSeenHR = hrKey;
        }
        const relay = this.opts.relayUrls[this.relayIndex];
        this.relayIndex = (this.relayIndex + 1) % this.opts.relayUrls.length;
        const [, precommitsAtRound] = await Promise.all([
          this.fetchAndIngest(relay, height, round, "prevote"),
          this.fetchAndIngest(relay, height, round, "precommit")
        ]);
        const allPrecommits = [...precommitsAtRound];
        if (round > 0n) {
          const PAST_ROUND_PROBE_CAP = 8n;
          const probeUpTo = round < PAST_ROUND_PROBE_CAP ? round : PAST_ROUND_PROBE_CAP;
          const probes = [];
          for (let r = 0n; r < probeUpTo; r += 1n) {
            probes.push(this.fetchOnly(relay, height, r, "precommit"));
          }
          const fetched = await Promise.all(probes);
          for (const batch of fetched) allPrecommits.push(...batch);
        }
        if (allPrecommits.length > 0) {
          try {
            await this.opts.node.detectAndSignLatePolka(height, allPrecommits);
          } catch (err) {
            this.opts.log(
              `detectAndSignLatePolka threw (h=${height}): ${String(err instanceof Error ? err.message : err)}`
            );
          }
        }
      }
      /**
       * Fetch + decode all votes for (h, r, kind) from a relay. Used by
       * the round-0 probe in `tick` to feed Phase 4's detector without
       * touching engine state.
       */
      async fetchOnly(relay, height, round, kind) {
        const url = `${relay}/consensus/votes?height=${height}&round=${round}&kind=${kind}`;
        let body;
        try {
          const ctrl = new AbortController();
          const t = setTimeout(() => ctrl.abort(), this.opts.timeoutMs);
          try {
            const res = await fetch(url, { signal: ctrl.signal });
            if (!res.ok) {
              this.consecutiveFetchFails = 0;
              return [];
            }
            body = await res.json();
            this.consecutiveFetchFails = 0;
          } finally {
            clearTimeout(t);
          }
        } catch {
          this.consecutiveFetchFails++;
          return [];
        }
        if (!body.available || !body.votes || body.votes.length === 0) return [];
        const out = [];
        for (const hex2 of body.votes) {
          try {
            const bytes = hexToBytes13(hex2);
            out.push(SignedVoteSchema5.deserialize(bytes));
          } catch (err) {
            this.opts.log(`relay ${relay} returned invalid SSZ vote (${kind}): ${String(err)}`);
          }
        }
        return out;
      }
      async fetchAndIngest(relay, height, round, kind) {
        const parsed = await this.fetchOnly(relay, height, round, kind);
        if (parsed.length === 0) return [];
        let ingested = 0;
        for (const vote of parsed) {
          const dedupKey = `${kind}:${height}:${round}:${bytesToHexLower4(vote.body.validator)}`;
          if (this.ingestedKeys.has(dedupKey)) continue;
          this.ingestedKeys.add(dedupKey);
          const result = this.opts.node.ingestConsensusVote(vote);
          if (result.ok) ingested++;
        }
        if (ingested > 0) {
          this.opts.log(`ingested ${ingested} ${kind}(s) h=${height} r=${round} from ${relay}`);
        }
        return parsed;
      }
    };
  }
});

// src/bin/run.ts
import { homedir } from "os";
import { dirname as dirname4, join as join7 } from "path";
import { fileURLToPath, pathToFileURL } from "url";
import {
  BlockSchema as BlockSchema5,
  SignedTransactionSchema as SignedTransactionSchema7,
  SignedVoteSchema as SignedVoteSchema6
} from "@asentum/types";

// src/net/http-sync-loop.ts
var HttpSyncLoop = class {
  constructor(options) {
    this.options = options;
  }
  options;
  timer;
  running = false;
  inFlight = false;
  start() {
    if (this.running) return;
    this.running = true;
    this.timer = setInterval(() => {
      void this.tick();
    }, this.options.intervalMs);
    void this.tick();
  }
  stop() {
    this.running = false;
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = void 0;
    }
  }
  async tick() {
    if (this.inFlight) return;
    this.inFlight = true;
    try {
      await this.syncOnce();
    } catch (err) {
      const log = this.options.log ?? (() => void 0);
      log(`[sync] tick failed: ${err instanceof Error ? err.message : String(err)}`);
    } finally {
      this.inFlight = false;
    }
  }
  async syncOnce() {
    const localHeight = this.options.node.getChainInfo().height;
    const chainRes = await fetch(`${this.options.peerRpcUrl}/chain`);
    if (!chainRes.ok) {
      throw new Error(`peer /chain: ${chainRes.status} ${chainRes.statusText}`);
    }
    const chain = await chainRes.json();
    const peerHeight = BigInt(chain.height);
    if (peerHeight <= localHeight) return;
    const log = this.options.log ?? (() => void 0);
    const gap = peerHeight - localHeight;
    if (gap > 5n) {
      log(`[sync] fetching ${gap} blocks (${localHeight + 1n}..${peerHeight})`);
    }
    const BATCH = 32n;
    for (let h = localHeight + 1n; h <= peerHeight; h += BATCH) {
      const batchEnd = h + BATCH - 1n > peerHeight ? peerHeight : h + BATCH - 1n;
      const promises = [];
      for (let b = h; b <= batchEnd; b++) {
        promises.push(this.fetchBlockBytes(b));
      }
      const blocks = await Promise.all(promises);
      for (const bytes of blocks) {
        await this.options.node.applyExternalBlock(bytes);
      }
    }
  }
  async fetchBlockBytes(height) {
    const blockRes = await fetch(`${this.options.peerRpcUrl}/block-raw/${height.toString()}`);
    if (!blockRes.ok) {
      throw new Error(`peer /block-raw/${height}: ${blockRes.status} ${blockRes.statusText}`);
    }
    const body = await blockRes.json();
    if (body.error || !body.bytes) {
      throw new Error(`peer block ${height} missing: ${body.error ?? "no bytes"}`);
    }
    return parseHex(body.bytes);
  }
};
function parseHex(hex2) {
  const clean = hex2.startsWith("0x") || hex2.startsWith("0X") ? hex2.slice(2) : hex2;
  if (clean.length % 2 !== 0) throw new Error("invalid hex: odd length");
  const bytes = new Uint8Array(clean.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    const byte = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(byte)) throw new Error(`invalid hex at position ${i * 2}`);
    bytes[i] = byte;
  }
  return bytes;
}

// src/net/network.ts
import { Worker } from "worker_threads";
function blocksTopic(chainId) {
  return `/asentum/${chainId.toString()}/blocks/1.0.0`;
}
function txsTopic(chainId) {
  return `/asentum/${chainId.toString()}/txs/1.0.0`;
}
var BLOCKS_TOPIC = blocksTopic(1);
var TXS_TOPIC = txsTopic(1);
var NetworkLayer = class {
  worker;
  options;
  peerId = "";
  multiaddrs = [];
  connectedPeers = /* @__PURE__ */ new Set();
  constructor(options) {
    this.options = options;
  }
  async start() {
    this.worker = new Worker(this.options.workerUrl);
    const started = new Promise((resolve, reject) => {
      const onMsg = (msg) => {
        if (msg.type === "started") {
          const m = msg;
          this.peerId = m.peerId;
          this.multiaddrs = m.multiaddrs;
          this.worker?.off("message", onMsg);
          resolve();
        } else if (msg.type === "error") {
          if (this.peerId === "") {
            this.worker?.off("message", onMsg);
            reject(new Error(`worker start error: ${String(msg.message)}`));
          }
        }
      };
      this.worker?.on("message", onMsg);
    });
    this.worker.on("message", (msg) => {
      this.handleWorkerMessage(msg);
    });
    this.worker.on("error", (err) => {
      console.error("[p2p-worker] crashed:", err);
    });
    this.worker.on("exit", (code) => {
      if (code !== 0) {
        console.error(`[p2p-worker] exited with code ${code}`);
      }
    });
    this.worker.postMessage({
      type: "start",
      opts: {
        chainId: typeof this.options.chainId === "bigint" ? Number(this.options.chainId) : this.options.chainId,
        listenPort: this.options.listenPort ?? 0,
        listenHost: this.options.listenHost ?? "0.0.0.0"
      }
    });
    await started;
  }
  handleWorkerMessage(msg) {
    const type = msg.type;
    if (type === "received-block") {
      void Promise.resolve(this.options.onBlockReceived(msg.bytes)).catch((err) => {
        console.error("[net] onBlockReceived failed:", err);
      });
    } else if (type === "received-tx") {
      void Promise.resolve(this.options.onTransactionReceived(msg.bytes)).catch((err) => {
        console.error("[net] onTransactionReceived failed:", err);
      });
    } else if (type === "received-vote") {
      const h = this.options.onVoteReceived;
      if (h) {
        void Promise.resolve(h(msg.bytes)).catch((err) => {
          console.error("[net] onVoteReceived failed:", err);
        });
      }
    } else if (type === "received-proposal") {
      const h = this.options.onProposalReceived;
      if (h) {
        void Promise.resolve(h(msg.bytes)).catch((err) => {
          console.error("[net] onProposalReceived failed:", err);
        });
      }
    } else if (type === "peer-event") {
      const peerId = msg.peerId;
      if (msg.event === "connect") {
        this.connectedPeers.add(peerId);
        console.log("[p2p] peer:connect", peerId);
      } else {
        this.connectedPeers.delete(peerId);
        console.log("[p2p] peer:disconnect", peerId);
      }
    } else if (type === "error") {
      console.error("[p2p-worker]", msg.message);
    }
  }
  async stop() {
    if (!this.worker) return;
    this.worker.postMessage({ type: "stop" });
    await this.worker.terminate();
    this.worker = void 0;
  }
  async connectToPeer(multiaddrStr) {
    if (!this.worker) throw new Error("network layer not started");
    this.worker.postMessage({ type: "dial-peer", multiaddr: multiaddrStr });
  }
  async publishBlock(bytes) {
    this.publish("publish-block", bytes);
  }
  async publishTransaction(bytes) {
    this.publish("publish-tx", bytes);
  }
  async publishVote(bytes) {
    this.publish("publish-vote", bytes);
  }
  async publishProposal(bytes) {
    this.publish("publish-proposal", bytes);
  }
  publish(type, bytes) {
    if (!this.worker) return;
    const copy = new Uint8Array(bytes.length);
    copy.set(bytes);
    this.worker.postMessage({ type, bytes: copy }, [copy.buffer]);
  }
  getMultiaddrs() {
    return this.multiaddrs;
  }
  getPeerId() {
    return this.peerId;
  }
  getConnectedPeers() {
    return Array.from(this.connectedPeers);
  }
  /** Compatibility shim — pubsub peer enumeration moved into the worker
   * (no in-thread access). Returns empty until/unless we add an IPC
   * query for it. */
  getPubSubPeers(_topic) {
    return [];
  }
};

// src/net/sync.ts
async function initialSync(options) {
  const { node, peerRpcUrl, log = () => void 0 } = options;
  const heightBefore = node.getChainInfo().height;
  const chainRes = await fetch(`${peerRpcUrl}/chain`);
  if (!chainRes.ok) {
    throw new Error(`peer /chain failed: ${chainRes.status} ${chainRes.statusText}`);
  }
  const chain = await chainRes.json();
  const peerHeight = BigInt(chain.height);
  if (peerHeight <= heightBefore) {
    log(`already at peer height (${peerHeight}); no sync needed`);
    return { heightBefore, heightAfter: heightBefore, blocksApplied: 0 };
  }
  log(`syncing blocks ${heightBefore + 1n}..${peerHeight} from ${peerRpcUrl}`);
  let applied = 0;
  for (let h = heightBefore + 1n; h <= peerHeight; h++) {
    const blockRes = await fetch(`${peerRpcUrl}/block-raw/${h.toString()}`);
    if (!blockRes.ok) {
      throw new Error(`peer /block-raw/${h} failed: ${blockRes.status} ${blockRes.statusText}`);
    }
    const body = await blockRes.json();
    if (body.error || !body.bytes) {
      throw new Error(`peer block ${h} missing: ${body.error ?? "no bytes"}`);
    }
    const bytes = parseHex2(body.bytes);
    await node.applyExternalBlock(bytes);
    applied++;
    if (applied % 10 === 0) {
      log(`  synced block ${h}`);
    }
  }
  const heightAfter = node.getChainInfo().height;
  log(`sync complete: ${heightBefore} \u2192 ${heightAfter} (${applied} blocks)`);
  return { heightBefore, heightAfter, blocksApplied: applied };
}
function parseHex2(hex2) {
  const clean = hex2.startsWith("0x") || hex2.startsWith("0X") ? hex2.slice(2) : hex2;
  if (clean.length % 2 !== 0) throw new Error("invalid hex: odd length");
  const bytes = new Uint8Array(clean.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    const byte = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(byte)) throw new Error(`invalid hex at position ${i * 2}`);
    bytes[i] = byte;
  }
  return bytes;
}

// src/net/vote-broadcast.ts
init_sign();
import { BlockSchema } from "@asentum/types";
var voteBroadcastStats = {
  perPeer: /* @__PURE__ */ new Map()
};
var VoteBroadcaster = class {
  peers;
  log;
  timeoutMs;
  hardCapMs;
  cooldownFailureThreshold;
  cooldownMs;
  maxConcurrent;
  degradedTimeoutMs;
  /**
   * Semaphore state for bounded outbound concurrency. `inFlight` counts
   * fetches currently pending. When `inFlight < maxConcurrent`, a new
   * fetch fires immediately; otherwise it queues on `pendingResolvers`
   * and fires as slots free.
   *
   * Why this exists: without a cap, broadcast() spawns N-1 concurrent
   * fetches, each holding an 8s AbortController. Under committee scale-
   * up (N=10-20) + unresponsive peers, that's dozens of pending sockets
   * dominating the event loop and starving the local RPC handler so
   * peer inbound votes never land. Confirmed 2026-07-14 on Hetzner
   * cert harness at N=10 (task #215 escalation).
   */
  inFlight = 0;
  pendingResolvers = [];
  /**
   * Per-peer health state. Populated lazily on the first failure.
   * Reachable peers stay out of the map entirely so the common case
   * costs nothing. Preserved across setPeers() so an epoch refresh of
   * the chain-derived peer list does not wipe accumulated context for
   * peers that survive the refresh.
   */
  peerHealth = /* @__PURE__ */ new Map();
  /**
   * Per-peer drop counters live in the module-level `voteBroadcastStats`
   * so node.ts can surface them on /consensus/liveness without a
   * broadcaster reference. `abortedByTimeout` fires when the per-peer
   * fetch AbortController triggered before the response came back —
   * that's the dominant signal that our timeouts are too tight (task
   * #346 aftermath 2026-07-09).
   */
  constructor(opts) {
    this.peers = [...opts.peers];
    this.log = opts.log ?? ((m) => console.error(m));
    this.timeoutMs = opts.timeoutMs ?? Number(process.env.ASENTUM_VOTE_BROADCAST_TIMEOUT_MS ?? "8000");
    this.hardCapMs = opts.hardCapMs ?? Number(process.env.ASENTUM_VOTE_BROADCAST_HARDCAP_MS ?? "8000");
    this.cooldownFailureThreshold = opts.cooldownFailureThreshold ?? 3;
    this.cooldownMs = opts.cooldownMs ?? 6e4;
    this.maxConcurrent = opts.maxConcurrent ?? Number(process.env.ASENTUM_VOTE_BROADCAST_MAX_CONCURRENT ?? "6");
    this.degradedTimeoutMs = opts.degradedTimeoutMs ?? Number(process.env.ASENTUM_VOTE_BROADCAST_DEGRADED_TIMEOUT_MS ?? "1500");
  }
  /**
   * Acquire a concurrency slot. Resolves immediately when inFlight is
   * below the cap; otherwise queues until a slot frees. Combined with
   * `releaseSlot()` in the fetch finally block, gives bounded parallel
   * fanout across broadcast targets.
   */
  async acquireSlot() {
    if (this.inFlight < this.maxConcurrent) {
      this.inFlight += 1;
      return;
    }
    await new Promise((resolve) => {
      this.pendingResolvers.push(resolve);
    });
    this.inFlight += 1;
  }
  releaseSlot() {
    this.inFlight -= 1;
    const next = this.pendingResolvers.shift();
    if (next) next();
  }
  bumpStat(peer, key6) {
    let s = voteBroadcastStats.perPeer.get(peer);
    if (!s) {
      s = { sent: 0, ok: 0, abortedByTimeout: 0, otherFailures: 0 };
      voteBroadcastStats.perPeer.set(peer, s);
    }
    s[key6] += 1;
  }
  /**
   * Decide whether `peer` should receive the next broadcast given its
   * recent failure history. Peers with no recorded failures always
   * return true. Peers past the threshold are skipped until the
   * cooldown elapses, then a single probation attempt is allowed.
   */
  peerEligible(peer, now) {
    const h = this.peerHealth.get(peer);
    if (!h) return true;
    if (h.consecutiveFailures < this.cooldownFailureThreshold) return true;
    return now - h.lastFailureAt >= this.cooldownMs;
  }
  /**
   * Replace the peer list. Used by the chain-level peer discovery flow:
   * the node recomputes the broadcast targets each epoch from the on-chain
   * staking contract's per-validator `broadcastUrl` field and pushes the
   * resulting list down here. Empty list disables outbound broadcasts.
   * Caller is responsible for filtering self-URLs.
   */
  setPeers(peers) {
    this.peers = [...peers];
  }
  /**
   * POST the vote (hex-encoded SSZ) to every configured peer except any
   * URL passed in `excludePeers`. Used so re-gossip doesn't echo a vote
   * back to its original sender. All requests are issued in parallel
   * and individual failures are logged but never thrown.
   */
  async broadcast(vote, excludePeers = []) {
    if (this.peers.length === 0) return;
    const exclude = new Set(excludePeers);
    const now = Date.now();
    const targets = this.peers.filter((p) => !exclude.has(p) && this.peerEligible(p, now));
    if (targets.length === 0) return;
    const rawVoteHex = bytesToHex(encodeSignedVote(vote));
    const sends = targets.map(
      (peer) => this.sendOne(`${peer}/consensus/vote`, { rawVote: rawVoteHex }, "[vote-broadcast]", peer)
    );
    await this.raceWithHardCap(sends);
  }
  /**
   * Push a freshly built block to every configured peer's
   * `/consensus/proposal` endpoint. Used by the producer at the start
   * of a BFT round so non-proposer validators can apply the block
   * locally and vote on it. Awaited so the producer doesn't enter
   * `waitForQuorum` until the proposal has been sent.
   */
  async broadcastProposal(block, round = 0n) {
    if (this.peers.length === 0) return;
    const now = Date.now();
    const targets = this.peers.filter((p) => this.peerEligible(p, now));
    if (targets.length === 0) return;
    const rawBlockHex = bytesToHex(BlockSchema.serialize(block));
    const roundStr = round.toString();
    const sends = targets.map(
      (peer) => this.sendOne(
        `${peer}/consensus/proposal`,
        { rawBlock: rawBlockHex, round: roundStr },
        "[proposal-broadcast]",
        peer
      )
    );
    await this.raceWithHardCap(sends);
  }
  /**
   * @dev POST one payload to one peer with its own AbortController.
   * Returns a promise that always resolves (never rejects), failures
   * are logged. Each fetch lives independently so a slow peer cannot
   * delay the others, and outstanding fetches keep running in the
   * background even after raceWithHardCap resolves the outer broadcast.
   */
  async sendOne(url, body, logTag, peer) {
    await this.acquireSlot();
    this.bumpStat(peer, "sent");
    let abortedByTimeout = false;
    const h = this.peerHealth.get(peer);
    const effectiveTimeoutMs = h && h.consecutiveFailures > 0 ? this.degradedTimeoutMs : this.timeoutMs;
    try {
      const ac = new AbortController();
      const timer = setTimeout(() => {
        abortedByTimeout = true;
        ac.abort();
      }, effectiveTimeoutMs);
      try {
        const res = await fetch(url, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
          signal: ac.signal
        });
        if (!res.ok) {
          this.log(`${logTag} peer ${peer} returned ${res.status}`);
          this.recordFailure(peer);
          this.bumpStat(peer, "otherFailures");
        } else {
          this.recordSuccess(peer);
          this.bumpStat(peer, "ok");
        }
      } finally {
        clearTimeout(timer);
      }
    } catch (err) {
      const inCooldown = h !== void 0 && h.consecutiveFailures >= this.cooldownFailureThreshold;
      if (!inCooldown) this.log(`${logTag} peer ${peer} unreachable: ${String(err)}`);
      this.recordFailure(peer);
      this.bumpStat(peer, abortedByTimeout ? "abortedByTimeout" : "otherFailures");
    } finally {
      this.releaseSlot();
    }
  }
  recordSuccess(peer) {
    this.peerHealth.delete(peer);
  }
  recordFailure(peer) {
    const cur = this.peerHealth.get(peer);
    this.peerHealth.set(peer, {
      lastFailureAt: Date.now(),
      consecutiveFailures: (cur?.consecutiveFailures ?? 0) + 1
    });
  }
  /**
   * @dev wait for all per-peer sends to settle, but never longer than
   * hardCapMs of wallclock time. Sends that don't finish in the cap
   * keep running in the background (they're already running as their
   * own promises), we just stop awaiting them so the caller (e.g.
   * the proposer's voting cycle) isn't head-of-line blocked by the
   * slowest peer. This was the 2026-05-21 stall pattern: cross-
   * continent peer at 1500ms RTT making every broadcast take 1500ms
   * even though the other peers had already received the proposal.
   */
  async raceWithHardCap(sends) {
    if (sends.length === 0) return;
    await Promise.race([
      Promise.allSettled(sends),
      new Promise((resolve) => setTimeout(resolve, this.hardCapMs))
    ]);
  }
  /** Number of configured peers (handy for startup banner). */
  peerCount() {
    return this.peers.length;
  }
  /** Read-only copy of the peer list. */
  getPeers() {
    return this.peers;
  }
};
function bytesToHex(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}

// 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/node/node.ts
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
import { join as join6 } from "path";
import {
  addressBytesFromPublicKey as addressBytesFromPublicKey8,
  blake3 as blake318,
  verify as verifyDilithium3
} from "@asentum/crypto";
import { StateStore } from "@asentum/state";
import {
  AuraBlockHeaderSchema as AuraBlockHeaderSchema4,
  BlockHeaderSchema as BlockHeaderSchema4,
  BlockSchema as BlockSchema3,
  LastCommitSchema as LastCommitSchema2,
  SignedTransactionSchema as SignedTransactionSchema5,
  SignedVoteSchema as SignedVoteSchema3,
  VOTE_KIND_PRECOMMIT as VOTE_KIND_PRECOMMIT3,
  VoteBodySchema as VoteBodySchema2,
  emptyLastCommit as emptyLastCommit3
} from "@asentum/types";
import { Vm } from "@asentum/vm";

// src/chain/apply.ts
import { addressBytesFromPublicKey as addressBytesFromPublicKey4, blake3 as blake36, verify as verify2 } from "@asentum/crypto";
import {
  TransactionBodySchema
} from "@asentum/types";

// src/chain/vm-integration.ts
import {
  addressBytesFromPublicKey as addressBytesFromPublicKey3,
  blake3 as blake35,
  verify
} from "@asentum/crypto";
import "@asentum/vm";

// src/chain/slashing-evidence.ts
init_sign();
import { addressBytesFromPublicKey as addressBytesFromPublicKey2 } from "@asentum/crypto";
import { SignedVoteSchema as SignedVoteSchema2 } from "@asentum/types";
function verifySlashingEvidence(args) {
  if (args.length < 3) {
    return { ok: false, reason: "recordSlash needs 3 args: [offender, voteA, voteB]" };
  }
  const [offenderArg, voteAArg, voteBArg] = args;
  if (typeof offenderArg !== "string") {
    return { ok: false, reason: "offender must be a hex string" };
  }
  if (typeof voteAArg !== "string" || typeof voteBArg !== "string") {
    return { ok: false, reason: "voteA and voteB must be hex strings" };
  }
  let voteA;
  let voteB;
  try {
    voteA = SignedVoteSchema2.deserialize(parseHexBytes(voteAArg));
  } catch (err) {
    return { ok: false, reason: `voteA decode failed: ${String(err)}` };
  }
  try {
    voteB = SignedVoteSchema2.deserialize(parseHexBytes(voteBArg));
  } catch (err) {
    return { ok: false, reason: `voteB decode failed: ${String(err)}` };
  }
  const verifyA = verifySignedVote(voteA);
  if (!verifyA.ok) return { ok: false, reason: `voteA invalid: ${verifyA.reason}` };
  const verifyB = verifySignedVote(voteB);
  if (!verifyB.ok) return { ok: false, reason: `voteB invalid: ${verifyB.reason}` };
  const addrA = addressBytesFromPublicKey2(voteA.publicKey);
  const addrB = addressBytesFromPublicKey2(voteB.publicKey);
  if (!bytesEqual2(addrA, addrB)) {
    return { ok: false, reason: "voteA and voteB are from different validators" };
  }
  const offenderHex = "0x" + bytesToHexLower(addrA);
  if (offenderArg.toLowerCase() !== offenderHex) {
    return {
      ok: false,
      reason: `offender arg ${offenderArg} doesn't match votes' validator ${offenderHex}`
    };
  }
  if (voteA.body.chainId !== voteB.body.chainId) {
    return { ok: false, reason: "chainId mismatch between votes" };
  }
  if (voteA.body.height !== voteB.body.height) {
    return { ok: false, reason: "height mismatch between votes" };
  }
  if (voteA.body.round !== voteB.body.round) {
    return { ok: false, reason: "round mismatch between votes" };
  }
  if (voteA.body.kind !== voteB.body.kind) {
    return { ok: false, reason: "kind mismatch between votes (prevote vs precommit)" };
  }
  if (bytesEqual2(voteA.body.blockHash, voteB.body.blockHash)) {
    return { ok: false, reason: "voteA and voteB are for the same blockHash (not a conflict)" };
  }
  return { ok: true };
}
function parseHexBytes(hex2) {
  let h = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  if (h.length % 2 !== 0) throw new Error("hex string has odd length");
  const out = new Uint8Array(h.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}
function bytesToHexLower(bytes) {
  let hex2 = "";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
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;
}

// src/chain/system-contracts.ts
import { blake3 as blake34 } from "@asentum/crypto";
import "@asentum/types";

// src/chain/cron-registry.ts
import { blake3 as blake32 } from "@asentum/crypto";
import "@asentum/types";
function bytesToHex2(bytes) {
  let h = "0x";
  for (let i = 0; i < bytes.length; i++) h += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  return h;
}
var CRON_REGISTRY_ADDRESS = (() => {
  const addr = new Uint8Array(20);
  addr[19] = 6;
  return addr;
})();
var CRON_BLOCK_GAS_BUDGET = 15000000n;
var CHAIN_CALLER = new Uint8Array(20);
var CRON_REGISTRY_SOURCE = `
const NEXT_JOB_ID_KEY = 'nextJobId';
const JOB_PREFIX = 'job:';
const DUE_PREFIX = 'due:';
const OWNER_PREFIX = 'owner:';
const CHAIN_CALLER = '0x0000000000000000000000000000000000000000';
const MIN_INTERVAL = 60n;
const MAX_INTERVAL = 31_536_000_000n; // 1000 years, safety cap
const MAX_GAS_LIMIT = 5_000_000n;
const MIN_GAS_LIMIT = 21_000n;

// Right-pad a unix timestamp to 14 digits so lexicographic storage
// key sort matches numeric sort. Years until ~5138 fit in 14 digits.
function tsKey(ts) {
  return String(ts).padStart(14, '0');
}

({
  // Schedule a recurring or one-shot call.
  // intervalSec = 0 \u2192 one-shot, fires once and is removed.
  // maxRuns = 0    \u2192 run forever, until escrow is exhausted or cancel().
  // gasLimit is per-run, in gas units. Escrow must cover at least one run
  // at the current base fee.
  schedule(target, method, args, nextRunAt, intervalSec, maxRuns, gasLimit) {
    assert(typeof target === 'string' && /^0x[0-9a-fA-F]{40}$/.test(target),
      'invalid target address');
    assert(typeof method === 'string' && method.length > 0, 'method required');

    const next = BigInt(nextRunAt);
    assert(next > BigInt(chain.blockTimestamp), 'nextRunAt must be in the future');

    const interval = BigInt(intervalSec);
    assert(
      interval === 0n || (interval >= MIN_INTERVAL && interval <= MAX_INTERVAL),
      'interval must be 0 or between MIN_INTERVAL and MAX_INTERVAL seconds',
    );

    const gas = BigInt(gasLimit);
    assert(gas >= MIN_GAS_LIMIT && gas <= MAX_GAS_LIMIT, 'gasLimit out of range');

    const maxR = BigInt(maxRuns);
    assert(maxR >= 0n, 'maxRuns must be non-negative');

    const escrow = BigInt(msg.value);
    const baseFee = BigInt(chain.baseFee || 1n);
    assert(escrow >= gas * baseFee, 'escrow must cover at least one run at the current base fee');

    const id = String(BigInt(storage.get(NEXT_JOB_ID_KEY) || '0') + 1n);
    storage.set(NEXT_JOB_ID_KEY, id);

    const job = {
      id,
      owner: String(msg.sender).toLowerCase(),
      target: target.toLowerCase(),
      method,
      args: args || [],
      nextRunAt: next.toString(),
      intervalSec: interval.toString(),
      maxRuns: maxR.toString(),
      runsLeft: maxR.toString(),
      gasLimit: gas.toString(),
      gasEscrow: escrow.toString(),
    };
    storage.set(JOB_PREFIX + id, JSON.stringify(job));
    storage.set(DUE_PREFIX + tsKey(job.nextRunAt) + ':' + id, '1');
    storage.set(OWNER_PREFIX + job.owner + ':' + id, '1');

    emit('JobScheduled', {
      id,
      owner: job.owner,
      target: job.target,
      method,
      nextRunAt: job.nextRunAt,
      intervalSec: job.intervalSec,
    });
    return id;
  },

  // Top up an existing job's gas escrow. Anyone can top up any job
  // (good for sponsored execution patterns).
  topUp(id) {
    const raw = storage.get(JOB_PREFIX + id);
    assert(raw, 'no such job');
    const job = JSON.parse(raw);
    job.gasEscrow = (BigInt(job.gasEscrow) + BigInt(msg.value)).toString();
    storage.set(JOB_PREFIX + id, JSON.stringify(job));
    emit('JobToppedUp', { id, amount: String(msg.value), totalEscrow: job.gasEscrow });
    return true;
  },

  // Cancel a job. Only callable by the job's owner. Refunds remaining
  // escrow to the owner via transfer().
  cancel(id) {
    const raw = storage.get(JOB_PREFIX + id);
    assert(raw, 'no such job');
    const job = JSON.parse(raw);
    assert(job.owner === String(msg.sender).toLowerCase(), 'not the owner');
    storage.delete(JOB_PREFIX + id);
    storage.delete(DUE_PREFIX + tsKey(job.nextRunAt) + ':' + id);
    storage.delete(OWNER_PREFIX + job.owner + ':' + id);
    const refund = BigInt(job.gasEscrow);
    if (refund > 0n) transfer(msg.sender, refund);
    emit('JobCancelled', { id, owner: job.owner, refunded: refund.toString() });
    return true;
  },

  // Read view: full job record or null.
  getJob(id) {
    const raw = storage.get(JOB_PREFIX + id);
    return raw ? JSON.parse(raw) : null;
  },

  // List due job ids up to currentTimestamp. Caller (the chain block
  // builder) walks this list and dispatches each one. Capped so a huge
  // backlog can't return an unbounded array.
  // Only callable by the chain itself (msg.sender = zero address).
  listDue(currentTimestamp, max) {
    assert(String(msg.sender).toLowerCase() === CHAIN_CALLER, 'restricted');
    const ts = BigInt(currentTimestamp);
    const cap = Number(max || 100);
    const ids = [];
    // Walk DUE_PREFIX in ascending order.
    for (const key of storage.keys(DUE_PREFIX)) {
      if (ids.length >= cap) break;
      // key shape: "due:<paddedTs>:<id>"
      const rest = key.slice(DUE_PREFIX.length);
      const colon = rest.indexOf(':');
      if (colon < 0) continue;
      const keyTs = BigInt(rest.slice(0, colon));
      if (keyTs > ts) break; // sorted, so anything after is in the future
      const id = rest.slice(colon + 1);
      ids.push(id);
    }
    return ids;
  },

  // Mark a job as having run. Decrement runsLeft, deduct escrow,
  // re-insert in due index at nextRunAt + intervalSec (or remove if
  // one-shot / out of runs / out of escrow). Only callable by chain.
  recordRun(id, gasUsed, success) {
    assert(String(msg.sender).toLowerCase() === CHAIN_CALLER, 'restricted');
    const raw = storage.get(JOB_PREFIX + id);
    if (!raw) return false;
    const job = JSON.parse(raw);

    const baseFee = BigInt(chain.baseFee || 1n);
    const cost = BigInt(gasUsed) * baseFee;
    let escrow = BigInt(job.gasEscrow) - cost;
    if (escrow < 0n) escrow = 0n;

    storage.delete(DUE_PREFIX + tsKey(job.nextRunAt) + ':' + id);

    const runsLeft = BigInt(job.runsLeft);
    const interval = BigInt(job.intervalSec);
    const maxRuns = BigInt(job.maxRuns);
    const newRunsLeft = maxRuns === 0n ? 0n : (runsLeft - 1n);

    emit('JobExecuted', { id, success: !!success, gasUsed: String(gasUsed), escrowLeft: escrow.toString() });

    // One-shot OR runs exhausted OR no escrow \u2192 remove.
    const isOneShot = interval === 0n;
    const exhaustedRuns = maxRuns !== 0n && newRunsLeft === 0n;
    const exhaustedEscrow = escrow < BigInt(job.gasLimit) * baseFee;

    if (isOneShot || exhaustedRuns || exhaustedEscrow) {
      storage.delete(JOB_PREFIX + id);
      storage.delete(OWNER_PREFIX + job.owner + ':' + id);
      const reason = isOneShot ? 'one-shot complete' : exhaustedRuns ? 'max runs reached' : 'escrow exhausted';
      emit('JobExhausted', { id, reason });
      if (escrow > 0n) transfer(job.owner, escrow);
      return true;
    }

    // Otherwise reschedule.
    const newNext = (BigInt(job.nextRunAt) + interval).toString();
    job.nextRunAt = newNext;
    job.runsLeft = newRunsLeft.toString();
    job.gasEscrow = escrow.toString();
    storage.set(JOB_PREFIX + id, JSON.stringify(job));
    storage.set(DUE_PREFIX + tsKey(newNext) + ':' + id, '1');
    return true;
  },
})
`;
async function installCronRegistry(opts) {
  const { store, vm, chainId } = opts;
  const sourceBytes = new TextEncoder().encode(CRON_REGISTRY_SOURCE);
  const codeHash = blake32(sourceBytes);
  await store.putCode(codeHash, sourceBytes);
  const zeroHash = new Uint8Array(32);
  await store.setAccount(CRON_REGISTRY_ADDRESS, {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot: zeroHash
  });
  const inMem = /* @__PURE__ */ new Map();
  vm.deploy(
    {
      source: CRON_REGISTRY_SOURCE,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex2(CRON_REGISTRY_ADDRESS),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: {
        get: (k) => inMem.get(k),
        set: (k, v) => {
          inMem.set(k, v);
        },
        delete: (k) => inMem.delete(k),
        has: (k) => inMem.has(k)
      },
      gasLimit: 10000000n
    },
    []
  );
  for (const [k, v] of inMem) {
    await store.setStorageValue(CRON_REGISTRY_ADDRESS, k, v);
  }
  const storageRoot = await store.computeContractStorageRoot(CRON_REGISTRY_ADDRESS);
  await store.setAccount(CRON_REGISTRY_ADDRESS, {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot
  });
}

// src/chain/vault-registry.ts
import { blake3 as blake33 } from "@asentum/crypto";
import "@asentum/types";

// src/chain/vault-contract-sources.ts
var VAULT_CONTRACT_SOURCE = "// AsentumVault \u2014 encrypted secret storage with per-contract authorization\n// and event-driven Worker dispatch.\n//\n// Concept (full design in notes/asentum-workers-vault-design.md):\n//   - User encrypts an API key (or any secret \u22644 KB) to a Worker's\n//     public keypair off-chain, stores the ciphertext here.\n//   - User declares which contracts may TRIGGER decryption of that secret.\n//   - When an authorized contract calls requestDecryption(), the Vault\n//     emits a WorkRequested event scoped to the chosen Worker.\n//   - The Worker (off-chain process) sees the event, decrypts the\n//     ciphertext, performs the contract-specified action (usually an\n//     outbound HTTP call), and writes back a fulfillment via fulfillWork().\n//\n// Trust model: the Worker holds the decryption key. You trust the Worker\n// operator with the secret. What you GAIN over \"trust Vercel\" is that\n// every call is gated by a publicly-auditable contract: rules about when\n// to fire, who can trigger, what payload is allowed are all enforced by\n// the chain, not the Worker.\n//\n// Storage layout:\n//   secret:<sid>             \u2192 JSON { owner, workerId, ciphertext,\n//                                     authorized: [addrs], createdAt,\n//                                     updatedAt, expiresAt, deleted? }\n//   index:<owner>:<sid>      \u2192 '1'\n//   job:<jobId>              \u2192 JSON { sid, requester, payload, requestedAt,\n//                                     fulfilledAt?, status?, hash?,\n//                                     statusCode?, body? }\n//\n// sid format: hex sha256 of (owner + name + workerId), 64 hex chars.\n// jobId format: hex sha256 of (sid + nonce + requester + blockNumber),\n// 64 hex chars. Both computed off-chain by the caller \u2014 chain only\n// validates length/uniqueness.\n\nconst SECRET_PREFIX = 'secret:';\nconst OWNER_SECRET_INDEX_PREFIX = 'index:';\nconst JOB_PREFIX = 'job:';\n\nconst SID_HEX_LEN = 64;                  // 32-byte sha256\nconst JOB_ID_HEX_LEN = 64;\nconst MAX_CIPHERTEXT_HEX_LEN = 16384;    // ~8 KB binary, generous for \u22644 KB plaintext + overhead\nconst MAX_PAYLOAD_LEN = 2000;            // JSON-stringified payload cap\nconst MAX_BODY_LEN = 1024;               // full-body writeback cap (when resultMode === 'body')\nconst MAX_AUTHORIZED = 32;               // arbitrary, prevents griefing\n\n// Result writeback modes the contract owner can request:\n//   'hash' (default) \u2014 Worker writes back sha256(responseBody) only.\n//                       Privacy-preserving, cheaper, suits most cases.\n//   'body'           \u2014 Worker writes back the actual response body\n//                       (\u2264MAX_BODY_LEN bytes). For oracle-style use\n//                       where the contract needs the data.\nconst VALID_RESULT_MODES = ['hash', 'body'];\n\n({\n  // \u2500\u2500\u2500 Secret lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  // Store a new secret. Caller becomes the owner.\n  //\n  //   sid             secret ID (hex sha256(owner||name||workerId))\n  //   workerId        the Worker this ciphertext was encrypted to\n  //   ciphertext      hex-encoded hybrid X25519+ML-KEM ciphertext bundle\n  //   authorized      array of contract addresses allowed to trigger decryption\n  //   expiresAt       Unix seconds; after this, requestDecryption refuses\n  put(sid, workerId, ciphertext, authorized, expiresAt) {\n    const id = String(sid == null ? '' : sid).toLowerCase();\n    assert(/^[0-9a-f]+$/.test(id) && id.length === SID_HEX_LEN,\n      'sid must be ' + SID_HEX_LEN + ' hex chars');\n    assert(!storage.has(SECRET_PREFIX + id),\n      'secret already exists; use rotate() to overwrite');\n\n    const wid = String(workerId == null ? '' : workerId);\n    assert(wid.length > 0, 'workerId required');\n\n    const ct = String(ciphertext == null ? '' : ciphertext).toLowerCase();\n    assert(/^[0-9a-f]+$/.test(ct), 'ciphertext must be hex');\n    assert(ct.length <= MAX_CIPHERTEXT_HEX_LEN,\n      'ciphertext too long (max ' + MAX_CIPHERTEXT_HEX_LEN + ' hex chars)');\n\n    const authList = normalizeAuthorized(authorized);\n    const owner = String(msg.sender).toLowerCase();\n    const now = String(chain.blockNumber);\n    const exp = String(expiresAt == null || expiresAt === '' ? '0' : expiresAt);\n\n    const record = {\n      sid: id,\n      owner: owner,\n      workerId: wid,\n      ciphertext: ct,\n      authorized: authList,\n      createdAt: now,\n      updatedAt: now,\n      expiresAt: exp,\n      deleted: false,\n    };\n    storage.set(SECRET_PREFIX + id, JSON.stringify(record));\n    storage.set(OWNER_SECRET_INDEX_PREFIX + owner + ':' + id, '1');\n\n    emit('SecretPut', {\n      sid: id,\n      owner: owner,\n      workerId: wid,\n      authorizedCount: String(authList.length),\n    });\n    return true;\n  },\n\n  // Overwrite an existing secret (re-encrypt + rotate). Owner only.\n  // Used when the underlying API key is rotated, or when the Worker's\n  // pubkey changed (WorkerKeyRotated) and the secret needs re-encryption\n  // to the new pubkey.\n  rotate(sid, newWorkerId, newCiphertext, newExpiresAt) {\n    const id = String(sid).toLowerCase();\n    const raw = storage.get(SECRET_PREFIX + id);\n    assert(raw, 'no such secret');\n    const record = JSON.parse(raw);\n    assert(!record.deleted, 'secret is soft-deleted; un-delete first or put() a new sid');\n    assert(record.owner === String(msg.sender).toLowerCase(),\n      'only owner can rotate');\n\n    const ct = String(newCiphertext == null ? '' : newCiphertext).toLowerCase();\n    assert(/^[0-9a-f]+$/.test(ct), 'ciphertext must be hex');\n    assert(ct.length <= MAX_CIPHERTEXT_HEX_LEN,\n      'ciphertext too long (max ' + MAX_CIPHERTEXT_HEX_LEN + ' hex chars)');\n\n    const wid = String(newWorkerId == null ? '' : newWorkerId);\n    assert(wid.length > 0, 'workerId required');\n\n    record.workerId = wid;\n    record.ciphertext = ct;\n    record.expiresAt = String(newExpiresAt == null || newExpiresAt === '' ? record.expiresAt : newExpiresAt);\n    record.updatedAt = String(chain.blockNumber);\n    storage.set(SECRET_PREFIX + id, JSON.stringify(record));\n\n    emit('SecretRotated', { sid: id, owner: record.owner, workerId: wid });\n    return true;\n  },\n\n  // \u2500\u2500\u2500 Authorization \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  authorize(sid, contractAddr) {\n    const id = String(sid).toLowerCase();\n    const raw = storage.get(SECRET_PREFIX + id);\n    assert(raw, 'no such secret');\n    const record = JSON.parse(raw);\n    assert(record.owner === String(msg.sender).toLowerCase(),\n      'only owner can authorize');\n\n    const addr = String(contractAddr).toLowerCase();\n    assert(/^0x[0-9a-f]{40}$/.test(addr), 'contractAddr must be 0x-prefixed 20-byte hex');\n\n    if (record.authorized.indexOf(addr) === -1) {\n      assert(record.authorized.length < MAX_AUTHORIZED,\n        'authorized list at cap (' + MAX_AUTHORIZED + ')');\n      record.authorized.push(addr);\n      record.updatedAt = String(chain.blockNumber);\n      storage.set(SECRET_PREFIX + id, JSON.stringify(record));\n      emit('SecretAuthorized', { sid: id, contract: addr });\n    }\n    return true;\n  },\n\n  revoke(sid, contractAddr) {\n    const id = String(sid).toLowerCase();\n    const raw = storage.get(SECRET_PREFIX + id);\n    assert(raw, 'no such secret');\n    const record = JSON.parse(raw);\n    assert(record.owner === String(msg.sender).toLowerCase(),\n      'only owner can revoke');\n\n    const addr = String(contractAddr).toLowerCase();\n    const idx = record.authorized.indexOf(addr);\n    if (idx !== -1) {\n      record.authorized.splice(idx, 1);\n      record.updatedAt = String(chain.blockNumber);\n      storage.set(SECRET_PREFIX + id, JSON.stringify(record));\n      emit('SecretRevoked', { sid: id, contract: addr });\n    }\n    return true;\n  },\n\n  canUse(sid, contractAddr) {\n    const raw = storage.get(SECRET_PREFIX + String(sid).toLowerCase());\n    if (!raw) return false;\n    const record = JSON.parse(raw);\n    if (record.deleted) return false;\n    return record.authorized.indexOf(String(contractAddr).toLowerCase()) !== -1;\n  },\n\n  // \u2500\u2500\u2500 Decryption request flow \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  // Called by an AUTHORIZED contract (msg.sender check) to request the\n  // Worker decrypt a secret and perform the specified action.\n  //\n  // The contract crafts `payload` \u2014 typically a JSON object describing\n  // the outbound HTTP call: { url, method, body, headers, resultMode }.\n  // The Vault doesn't interpret the payload; the Worker does. This means\n  // the AUTHORIZING CONTRACT is the policy: whatever it chooses to put\n  // in `payload` is what the Worker will be asked to do. Workers also\n  // enforce their own per-host allowlist as defense in depth (see design\n  // doc \xA7 Worker protocol).\n  //\n  // jobId is provided by the caller (must be unique) so the contract\n  // can correlate the eventual fulfillment back to its own state.\n  requestDecryption(sid, jobId, payload) {\n    const id = String(sid).toLowerCase();\n    const raw = storage.get(SECRET_PREFIX + id);\n    assert(raw, 'no such secret');\n    const record = JSON.parse(raw);\n    assert(!record.deleted, 'secret is soft-deleted');\n\n    // expiry gate\n    if (record.expiresAt !== '0') {\n      const nowTs = BigInt(chain.blockTimestamp || 0);\n      assert(nowTs < BigInt(record.expiresAt),\n        'secret expired at ' + record.expiresAt);\n    }\n\n    // Authorization: contract caller must be in the authorized list.\n    const requester = String(msg.sender).toLowerCase();\n    assert(record.authorized.indexOf(requester) !== -1,\n      'caller not authorized for this secret');\n\n    // jobId uniqueness \u2014 prevents replay and lets contracts use jobId\n    // as a deterministic correlation key.\n    const jid = String(jobId == null ? '' : jobId).toLowerCase();\n    assert(/^[0-9a-f]+$/.test(jid) && jid.length === JOB_ID_HEX_LEN,\n      'jobId must be ' + JOB_ID_HEX_LEN + ' hex chars');\n    assert(!storage.has(JOB_PREFIX + jid),\n      'jobId already exists');\n\n    // Payload is opaque to the Vault; we just length-check it.\n    const pl = String(payload == null ? '' : payload);\n    assert(pl.length <= MAX_PAYLOAD_LEN,\n      'payload too long (max ' + MAX_PAYLOAD_LEN + ' chars)');\n\n    const job = {\n      jobId: jid,\n      sid: id,\n      requester: requester,\n      payload: pl,\n      workerId: record.workerId,\n      requestedAt: String(chain.blockNumber),\n      fulfilledAt: '',\n      status: 'pending',  // 'pending' | 'ok' | 'error'\n      hash: '',\n      statusCode: '',\n      body: '',\n      error: '',\n    };\n    storage.set(JOB_PREFIX + jid, JSON.stringify(job));\n\n    // Worker subscribes by workerId.\n    emit('WorkRequested', {\n      workerId: record.workerId,\n      jobId: jid,\n      sid: id,\n      requester: requester,\n      payload: pl,\n    });\n    return jid;\n  },\n\n  // \u2500\u2500\u2500 Fulfillment writeback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  // Called by the Worker after performing the HTTP call. Records the\n  // result on-chain. Idempotent: a second call for the same jobId fails.\n  //\n  // The Vault verifies that msg.sender is the registered OWNER of the\n  // workerId associated with this job \u2014 but for cross-contract verify\n  // we'd need to read WorkerRegistry. To keep this MVP self-contained\n  // and not depend on a cross-call (which the VM may or may not allow\n  // in the current rev), we instead let any address fulfill BUT we\n  // record `fulfillerAddress` in the event so contracts and indexers\n  // can verify the fulfiller's identity off-chain against the registry.\n  // V2: tighten this to an in-contract registry lookup if the VM\n  // supports cross-contract reads, or move registry data inline.\n  //\n  //   jobId         the job to fulfill\n  //   status        'ok' or 'error'\n  //   statusCode    HTTP status code from the upstream call (string)\n  //   hash          sha256 hex of response body (always written)\n  //   body          optional response body (only when contract requested\n  //                 resultMode='body' in payload; \u2264MAX_BODY_LEN bytes)\n  //   error         optional error message when status='error'\n  fulfillWork(jobId, status, statusCode, hash, body, error) {\n    const jid = String(jobId).toLowerCase();\n    const raw = storage.get(JOB_PREFIX + jid);\n    assert(raw, 'no such job');\n    const job = JSON.parse(raw);\n    assert(job.status === 'pending', 'job already fulfilled');\n\n    const st = String(status);\n    assert(st === 'ok' || st === 'error', 'status must be ok or error');\n\n    const ph = String(hash == null ? '' : hash).toLowerCase();\n    if (ph.length > 0) {\n      assert(/^[0-9a-f]+$/.test(ph) && ph.length === 64,\n        'hash must be 64 hex chars when present');\n    }\n\n    const bd = String(body == null ? '' : body);\n    assert(bd.length <= MAX_BODY_LEN,\n      'body too long (max ' + MAX_BODY_LEN + ' chars)');\n\n    job.fulfilledAt = String(chain.blockNumber);\n    job.status = st;\n    job.statusCode = String(statusCode == null ? '' : statusCode);\n    job.hash = ph;\n    job.body = bd;\n    job.error = String(error == null ? '' : error);\n    job.fulfiller = String(msg.sender).toLowerCase();\n    storage.set(JOB_PREFIX + jid, JSON.stringify(job));\n\n    emit('WorkFulfilled', {\n      workerId: job.workerId,\n      jobId: jid,\n      status: st,\n      statusCode: job.statusCode,\n      hash: ph,\n      fulfiller: job.fulfiller,\n    });\n\n    // Best-effort push-back: if the requester is a contract that implements\n    // onFulfilled(jobId, status, statusCode, hash, body, error), deliver the\n    // result straight into it (Chainlink-style callback). If it isn't a\n    // contract, or has no such method, or the callback reverts, we swallow the\n    // error \u2014 the result is already durably stored and readable via getJob().\n    // The callee sees msg.sender == this Vault, so it can trust the source.\n    if (/^0x[0-9a-f]{40}$/.test(job.requester)) {\n      try {\n        E(job.requester).onFulfilled(jid, st, job.statusCode, ph, bd, job.error);\n      } catch (_e) {\n        emit('CallbackFailed', { jobId: jid, requester: job.requester });\n      }\n    }\n    return true;\n  },\n\n  // \u2500\u2500\u2500 Deletion (soft only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  // Mark a secret as deleted. Owner only. The ciphertext remains in\n  // storage for audit purposes but requestDecryption() will refuse it.\n  // Hard-delete is intentionally not supported \u2014 preserves audit trail.\n  // To free the storage slot, the owner can rotate() to an empty\n  // ciphertext after delete().\n  delete(sid) {\n    const id = String(sid).toLowerCase();\n    const raw = storage.get(SECRET_PREFIX + id);\n    assert(raw, 'no such secret');\n    const record = JSON.parse(raw);\n    assert(record.owner === String(msg.sender).toLowerCase(),\n      'only owner can delete');\n    assert(!record.deleted, 'already deleted');\n\n    record.deleted = true;\n    record.updatedAt = String(chain.blockNumber);\n    storage.set(SECRET_PREFIX + id, JSON.stringify(record));\n\n    emit('SecretDeleted', { sid: id, owner: record.owner });\n    return true;\n  },\n\n  // \u2500\u2500\u2500 Views \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  getSecret(sid) {\n    const raw = storage.get(SECRET_PREFIX + String(sid).toLowerCase());\n    if (!raw) return null;\n    return JSON.parse(raw);\n  },\n\n  getJob(jobId) {\n    const raw = storage.get(JOB_PREFIX + String(jobId).toLowerCase());\n    if (!raw) return null;\n    return JSON.parse(raw);\n  },\n\n  hasSecret(sid) {\n    return storage.has(SECRET_PREFIX + String(sid).toLowerCase());\n  },\n});\n\nfunction normalizeAuthorized(input) {\n  if (input == null) return [];\n  // Accept either an array or a JSON string.\n  let arr = input;\n  if (typeof input === 'string') {\n    try { arr = JSON.parse(input); } catch { arr = []; }\n  }\n  if (!Array.isArray(arr)) return [];\n  assert(arr.length <= MAX_AUTHORIZED,\n    'authorized list too long (max ' + MAX_AUTHORIZED + ')');\n  const out = [];\n  for (let i = 0; i < arr.length; i++) {\n    const addr = String(arr[i]).toLowerCase();\n    assert(/^0x[0-9a-f]{40}$/.test(addr),\n      'authorized[' + i + '] must be 0x-prefixed 20-byte hex');\n    if (out.indexOf(addr) === -1) out.push(addr);\n  }\n  return out;\n}\n";
var WORKER_REGISTRY_SOURCE = "// AsentumWorkerRegistry \u2014 discovery + pubkey directory for Asentum Workers.\n//\n// A Worker is an off-chain process that decrypts Vault secrets and makes\n// HTTP calls on behalf of authorized contracts. Workers register here so\n// that:\n//   1. Users encrypting secrets can find a Worker's public encryption keys.\n//   2. The Vault can verify a fulfillment tx came from the registered owner.\n//   3. Anyone can audit which Workers were active at any block height.\n//\n// Per the design doc (notes/asentum-workers-vault-design.md), each Worker\n// holds a HYBRID encryption keypair: X25519 for the classical leg + ML-KEM-768\n// for the post-quantum leg. A secret is encrypted to BOTH legs and survives\n// either one being broken. This contract just stores the public halves +\n// the owner's chain address.\n//\n// Workers also commit to a policy hash (sha256 of allowed-hosts.json +\n// worker version). Lets auditors see exactly what HTTP destinations the\n// Worker was willing to call at any point in history.\n//\n// Storage layout:\n//   worker:<workerId>          \u2192 JSON { owner, x25519Pub, mlkemPub,\n//                                       broadcastUrl, policyHash, version,\n//                                       registeredAt, updatedAt, metadata }\n//   index:<owner>:<workerId>   \u2192 '1'   (lets us list a single owner's workers)\n//\n// All workerIds are case-sensitive opaque strings (e.g. \"asentum-default\",\n// \"acme-corp-prod\"). Recommend [a-z0-9-]{3,64}.\n\nconst WORKER_PREFIX = 'worker:';\nconst OWNER_INDEX_PREFIX = 'index:';\n\nconst MAX_WORKER_ID_LEN = 64;\nconst MAX_BROADCAST_URL_LEN = 200;\nconst MAX_METADATA_LEN = 1000;\nconst X25519_PUB_HEX_LEN = 64;        // 32 bytes\nconst MLKEM_PUB_HEX_LEN = 2368;       // 1184 bytes for ML-KEM-768\nconst POLICY_HASH_HEX_LEN = 64;       // 32 bytes (sha256)\n\n({\n  // Register a new Worker. Caller becomes the owner. Owner is the only\n  // address allowed to update / deregister this workerId.\n  //\n  //   workerId      opaque ID, becomes the discovery key\n  //   x25519Pub     hex public key (32 bytes / 64 hex chars)\n  //   mlkemPub      hex public key (1184 bytes / 2368 hex chars)\n  //   broadcastUrl  optional HTTP URL where the Worker accepts pushes\n  //   policyHash    hex sha256 of the Worker's allowed-hosts policy + version\n  //   version       freeform string (e.g. \"@asentum/worker@0.1.0\")\n  //   metadata      optional JSON-stringifiable extra fields\n  register(workerId, x25519Pub, mlkemPub, broadcastUrl, policyHash, version, metadata) {\n    const id = String(workerId == null ? '' : workerId);\n    assert(id.length > 0, 'workerId required');\n    assert(id.length <= MAX_WORKER_ID_LEN,\n      'workerId too long (max ' + MAX_WORKER_ID_LEN + ' chars)');\n    assert(!storage.has(WORKER_PREFIX + id),\n      'workerId already registered: ' + id);\n\n    const x25519 = String(x25519Pub == null ? '' : x25519Pub).toLowerCase();\n    const mlkem = String(mlkemPub == null ? '' : mlkemPub).toLowerCase();\n    const url = String(broadcastUrl == null ? '' : broadcastUrl);\n    const ph = String(policyHash == null ? '' : policyHash).toLowerCase();\n    const ver = String(version == null ? '' : version);\n    const meta = String(metadata == null ? '' : metadata);\n\n    assert(/^[0-9a-f]+$/.test(x25519) && x25519.length === X25519_PUB_HEX_LEN,\n      'x25519Pub must be ' + X25519_PUB_HEX_LEN + ' hex chars');\n    assert(/^[0-9a-f]+$/.test(mlkem) && mlkem.length === MLKEM_PUB_HEX_LEN,\n      'mlkemPub must be ' + MLKEM_PUB_HEX_LEN + ' hex chars');\n    assert(/^[0-9a-f]+$/.test(ph) && ph.length === POLICY_HASH_HEX_LEN,\n      'policyHash must be ' + POLICY_HASH_HEX_LEN + ' hex chars');\n    assert(url.length <= MAX_BROADCAST_URL_LEN,\n      'broadcastUrl too long (max ' + MAX_BROADCAST_URL_LEN + ' chars)');\n    assert(meta.length <= MAX_METADATA_LEN,\n      'metadata too long (max ' + MAX_METADATA_LEN + ' chars)');\n\n    const owner = String(msg.sender).toLowerCase();\n    const now = String(chain.blockNumber);\n\n    const record = {\n      workerId: id,\n      owner: owner,\n      x25519Pub: x25519,\n      mlkemPub: mlkem,\n      broadcastUrl: url,\n      policyHash: ph,\n      version: ver,\n      metadata: meta,\n      registeredAt: now,\n      updatedAt: now,\n    };\n    storage.set(WORKER_PREFIX + id, JSON.stringify(record));\n    storage.set(OWNER_INDEX_PREFIX + owner + ':' + id, '1');\n\n    emit('WorkerRegistered', {\n      workerId: id,\n      owner: owner,\n      policyHash: ph,\n      version: ver,\n    });\n    return true;\n  },\n\n  // Rotate a Worker's encryption keypair AND its policy hash. Owner only.\n  // Existing ciphertexts encrypted to the OLD pubkeys become unrecoverable\n  // unless the old key is also retained off-chain \u2014 but that's a per-Worker\n  // operational concern, the chain just records the rotation. Dependent\n  // contracts can react to WorkerKeyRotated to re-encrypt their secrets.\n  rotate(workerId, newX25519Pub, newMlkemPub, newPolicyHash, newVersion) {\n    const id = String(workerId);\n    const raw = storage.get(WORKER_PREFIX + id);\n    assert(raw, 'no such workerId: ' + id);\n    const record = JSON.parse(raw);\n    assert(record.owner === String(msg.sender).toLowerCase(),\n      'only owner can rotate');\n\n    const x25519 = String(newX25519Pub == null ? '' : newX25519Pub).toLowerCase();\n    const mlkem = String(newMlkemPub == null ? '' : newMlkemPub).toLowerCase();\n    const ph = String(newPolicyHash == null ? '' : newPolicyHash).toLowerCase();\n    const ver = String(newVersion == null ? '' : newVersion);\n\n    assert(/^[0-9a-f]+$/.test(x25519) && x25519.length === X25519_PUB_HEX_LEN,\n      'x25519Pub must be ' + X25519_PUB_HEX_LEN + ' hex chars');\n    assert(/^[0-9a-f]+$/.test(mlkem) && mlkem.length === MLKEM_PUB_HEX_LEN,\n      'mlkemPub must be ' + MLKEM_PUB_HEX_LEN + ' hex chars');\n    assert(/^[0-9a-f]+$/.test(ph) && ph.length === POLICY_HASH_HEX_LEN,\n      'policyHash must be ' + POLICY_HASH_HEX_LEN + ' hex chars');\n\n    record.x25519Pub = x25519;\n    record.mlkemPub = mlkem;\n    record.policyHash = ph;\n    record.version = ver;\n    record.updatedAt = String(chain.blockNumber);\n    storage.set(WORKER_PREFIX + id, JSON.stringify(record));\n\n    emit('WorkerKeyRotated', {\n      workerId: id,\n      owner: record.owner,\n      policyHash: ph,\n      version: ver,\n    });\n    return true;\n  },\n\n  // Update the off-chain endpoint where the Worker is reachable. Owner only.\n  // Lighter weight than rotate(): doesn't invalidate any secrets, just\n  // updates how to talk to the Worker. Use this for moving a Worker\n  // between hosts without rotating keys.\n  setBroadcastUrl(workerId, broadcastUrl) {\n    const id = String(workerId);\n    const raw = storage.get(WORKER_PREFIX + id);\n    assert(raw, 'no such workerId: ' + id);\n    const record = JSON.parse(raw);\n    assert(record.owner === String(msg.sender).toLowerCase(),\n      'only owner can update broadcastUrl');\n\n    const url = String(broadcastUrl == null ? '' : broadcastUrl);\n    assert(url.length <= MAX_BROADCAST_URL_LEN,\n      'broadcastUrl too long (max ' + MAX_BROADCAST_URL_LEN + ' chars)');\n\n    record.broadcastUrl = url;\n    record.updatedAt = String(chain.blockNumber);\n    storage.set(WORKER_PREFIX + id, JSON.stringify(record));\n\n    emit('WorkerEndpointUpdated', { workerId: id, broadcastUrl: url });\n    return true;\n  },\n\n  // Remove a Worker from the registry. Owner only.\n  // Secrets encrypted to this Worker remain on-chain but are now\n  // unaddressable \u2014 practical effect is they become orphan ciphertext.\n  // The owner should rotate dependent Vault secrets to a new Worker\n  // BEFORE deregistering, or after if recovery isn't needed.\n  deregister(workerId) {\n    const id = String(workerId);\n    const raw = storage.get(WORKER_PREFIX + id);\n    assert(raw, 'no such workerId: ' + id);\n    const record = JSON.parse(raw);\n    assert(record.owner === String(msg.sender).toLowerCase(),\n      'only owner can deregister');\n\n    storage.delete(WORKER_PREFIX + id);\n    storage.delete(OWNER_INDEX_PREFIX + record.owner + ':' + id);\n    emit('WorkerDeregistered', { workerId: id, owner: record.owner });\n    return true;\n  },\n\n  // View: full record for `workerId`, or null if not registered.\n  get(workerId) {\n    const id = String(workerId);\n    const raw = storage.get(WORKER_PREFIX + id);\n    if (!raw) return null;\n    return JSON.parse(raw);\n  },\n\n  // View: bool \u2014 is this workerId registered?\n  exists(workerId) {\n    return storage.has(WORKER_PREFIX + String(workerId));\n  },\n\n  // View: is `address` the registered owner of `workerId`?\n  // Vault uses this to verify a fulfillment tx is authentic.\n  isOwner(workerId, address) {\n    const raw = storage.get(WORKER_PREFIX + String(workerId));\n    if (!raw) return false;\n    const record = JSON.parse(raw);\n    return record.owner === String(address).toLowerCase();\n  },\n});\n";

// src/chain/vault-registry.ts
function bytesToHex3(bytes) {
  let h = "0x";
  for (let i = 0; i < bytes.length; i++) h += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  return h;
}
var VAULT_ADDRESS = (() => {
  const addr = new Uint8Array(20);
  addr[19] = 8;
  return addr;
})();
var WORKER_REGISTRY_ADDRESS = (() => {
  const addr = new Uint8Array(20);
  addr[19] = 9;
  return addr;
})();
async function installOne(opts, address, source) {
  const { store, vm, chainId } = opts;
  const sourceBytes = new TextEncoder().encode(source);
  const codeHash = blake33(sourceBytes);
  await store.putCode(codeHash, sourceBytes);
  const zeroHash = new Uint8Array(32);
  await store.setAccount(address, {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot: zeroHash
  });
  const inMem = /* @__PURE__ */ new Map();
  vm.deploy(
    {
      source,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex3(address),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: {
        get: (k) => inMem.get(k),
        set: (k, v) => {
          inMem.set(k, v);
        },
        delete: (k) => inMem.delete(k),
        has: (k) => inMem.has(k)
      },
      gasLimit: 10000000n
    },
    []
  );
  for (const [k, v] of inMem) {
    await store.setStorageValue(address, k, v);
  }
  const storageRoot = await store.computeContractStorageRoot(address);
  await store.setAccount(address, {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot
  });
}
async function installVaultContract(opts) {
  await installOne(opts, VAULT_ADDRESS, VAULT_CONTRACT_SOURCE);
}
async function installWorkerRegistry(opts) {
  await installOne(opts, WORKER_REGISTRY_ADDRESS, WORKER_REGISTRY_SOURCE);
}

// src/chain/system-contracts.ts
function reservedAddress(marker) {
  const addr = new Uint8Array(20);
  addr[19] = marker;
  return addr;
}
var STAKING_CONTRACT_ADDRESS = reservedAddress(1);
var SLASHING_CONTRACT_ADDRESS = reservedAddress(2);
var STAKING_CONTRACT_SOURCE = `
const INDEX_KEY = 'index';
const VALIDATOR_PREFIX = 'v:';
const WITHDRAW_PREFIX = 'w:';
// Per-epoch entry signal: 'ee:<epochNumber>' \u2192 JSON array of validator
// addresses that submitted submitEpochEntry for that epoch.
const EPOCH_ENTRY_PREFIX = 'ee:';

// Graceful quorum transition: newly bonded validators are "pending"
// for this many blocks before being included in the active validator
// set.
const PENDING_BLOCKS = 10;

// Staking v2 parameters.
// Values are in wei (1 ASE = 10^18 wei).
//
// MIN_SELF_BOND filters out spam-tier bonds. At presale-implied valuation
// (~$0.0019/ASE) 50,000 ASE \u2248 $95 \u2014 affordable for retail in low-cost
// regions, expensive enough that it's a real commitment. Genesis baked
// validators bypass this check (init() doesn't call bond()).
//
// MAX_SELF_BOND caps a single validator at 5% of total supply (1B cap).
// With delegation in v2 a validator's effective stake can grow far past
// this via delegated stake, but the self-bond ceiling prevents any one
// operator from concentrating in their own keypair.
const MIN_SELF_BOND_WEI = 500n * (10n ** 18n);            // 500 ASE
const MAX_SELF_BOND_WEI = 50000000n * (10n ** 18n);       // 50,000,000 ASE
// 30-day unbonding window at 2-second blocks = 1,296,000 blocks.
const UNBONDING_BLOCKS = 1296000n;

// \u2500\u2500\u2500 Asentum committee parameters \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
// These define the "stable core + rotating reserve" committee model.
// See docs/consensus-v1-plan.md for the architectural rationale.
const EPOCH_BLOCKS = 1440n;       // ~2 hours at 5s blocks
const COMMITTEE_CAP = 100;        // max active validators per epoch
const STABLE_CORE_SIZE = 50;      // top-K-by-stake, always active
const RESERVE_SIZE = 50;          // randomly-selected slots from "ready" bonded
// Last N blocks of an epoch where validators may submit submitEpochEntry
// for the NEXT epoch. Short window forces validators to be online recently.
const EPOCH_ENTRY_WINDOW = 100n;

// Chain-level peer discovery: every validator publishes a broadcastUrl
// on-chain at bond time so other validators can find them. Without this,
// the cluster relies on a hardcoded ASENTUM_PEERS env var per box and
// any new bond becomes a "ghost validator" \u2014 bonded but never receiving
// consensus messages because no peer knows their IP. URLs are capped to
// prevent state bloat and validated to prevent garbage that would crash
// downstream broadcast loops.
const MAX_BROADCAST_URL_LEN = 256;
function isValidBroadcastUrl(s) {
  if (typeof s !== 'string') return false;
  if (s.length === 0) return false;
  if (s.length > MAX_BROADCAST_URL_LEN) return false;
  // Lightweight check: must start with http:// or https:// and contain a host.
  // Rejecting malformed URLs at bond time keeps the broadcast loop fast \u2014 it
  // never has to try-catch a parse on every iteration.
  if (s.indexOf('http://') !== 0 && s.indexOf('https://') !== 0) return false;
  const rest = s.slice(s.indexOf('://') + 3);
  if (rest.length === 0) return false;
  return true;
}

// Chain-level ANT discovery: a validator publishes its ANT transport address
// (host:port) on-chain so the baked can dial + admit it as a consensus peer
// without anyone hand-editing an allowlist. Distinct from broadcastUrl (the
// http RPC endpoint); ANT is the raw consensus transport on its own port.
// Optional: if empty, peers fall back to deriving it from broadcastUrl's host
// plus the standard ANT port. Format is host:port, validated to keep dial
// loops fast and prevent state bloat.
const MAX_ANT_ADDR_LEN = 128;
function isValidAntAddr(s) {
  if (typeof s !== 'string') return false;
  if (s.length === 0 || s.length > MAX_ANT_ADDR_LEN) return false;
  const i = s.lastIndexOf(':');
  if (i <= 0 || i === s.length - 1) return false;
  const host = s.slice(0, i);
  const port = s.slice(i + 1);
  if (host.length === 0) return false;
  if (!/^[0-9]+$/.test(port)) return false;
  const p = Number(port);
  return p >= 1 && p <= 65535;
}

({
  init(args) {
    const initial = (args && args.initialValidators) || [];
    const addresses = [];
    for (const v of initial) {
      const addr = String(v.address).toLowerCase();
      addresses.push(addr);
      // Genesis validators may include a broadcastUrl. If absent (legacy
      // genesis specs), default to empty \u2014 they can submit
      // updateBroadcastUrl later. Empty URLs are tolerated for genesis-baked
      // validators only; runtime bond requires a valid URL.
      const broadcastUrl = v.broadcastUrl ? String(v.broadcastUrl) : '';
      const antAddr = v.antAddr ? String(v.antAddr) : '';
      storage.set(VALIDATOR_PREFIX + addr, JSON.stringify({
        address: addr,
        pubKey: String(v.pubKey),
        stake: String(v.stake),
        bondedAt: String(chain.blockNumber),
        broadcastUrl,
        antAddr,
      }));
    }
    storage.set(INDEX_KEY, JSON.stringify(addresses));
    emit('Genesis', { validatorCount: String(addresses.length) });
  },

  // The caller attaches ASE via msg.value; the chain has already credited
  // the staking contract's own balance with that amount before the method
  // runs. The staking contract's job is to track what portion of its
  // balance belongs to which validator's bonded stake.
  //
  // broadcastUrl is the http(s) endpoint other validators will use to
  // send this validator proposals and votes. Required and validated.
  // Without it, a bonded validator would be a "ghost" \u2014 bonded but never
  // reachable. Existing bonds calling bond() to top up may pass empty to
  // keep their current URL.
  bond(pubKey, broadcastUrl, antAddr) {
    const sender = String(msg.sender).toLowerCase();
    const amt = BigInt(msg.value);
    assert(amt > 0n, 'bond requires non-zero msg.value');

    const antGiven = antAddr !== undefined && antAddr !== null && antAddr !== '';
    if (antGiven) {
      assert(isValidAntAddr(antAddr), 'invalid antAddr: must be host:port');
    }

    const existingRaw = storage.get(VALIDATOR_PREFIX + sender);
    let validator;
    let newTotal;
    if (existingRaw) {
      validator = JSON.parse(existingRaw);
      newTotal = BigInt(validator.stake) + amt;
      // Top-up bond: broadcastUrl optional. If passed and valid, update.
      // If passed and INVALID, reject (don't silently keep the stale one).
      if (broadcastUrl !== undefined && broadcastUrl !== null && broadcastUrl !== '') {
        assert(isValidBroadcastUrl(broadcastUrl), 'invalid broadcastUrl: must be http(s)://host[:port], \u2264256 chars');
        validator.broadcastUrl = String(broadcastUrl);
      }
      if (antGiven) validator.antAddr = String(antAddr);
    } else {
      // First-time bond: broadcastUrl REQUIRED. antAddr optional (peers derive
      // it from broadcastUrl's host + standard ANT port if unset).
      assert(isValidBroadcastUrl(broadcastUrl), 'first bond requires a broadcastUrl (http(s)://host:port)');
      validator = {
        address: sender,
        pubKey: String(pubKey),
        stake: '0',
        bondedAt: String(chain.blockNumber),
        broadcastUrl: String(broadcastUrl),
        antAddr: antGiven ? String(antAddr) : '',
      };
      newTotal = amt;
    }

    assert(newTotal >= MIN_SELF_BOND_WEI, 'bond below MIN_SELF_BOND (500 ASE)');
    assert(newTotal <= MAX_SELF_BOND_WEI, 'bond above MAX_SELF_BOND (50,000,000 ASE)');

    if (!existingRaw) {
      const indexRaw = storage.get(INDEX_KEY) || '[]';
      const index = JSON.parse(indexRaw);
      if (index.indexOf(sender) < 0) {
        index.push(sender);
        storage.set(INDEX_KEY, JSON.stringify(index));
      }
    }

    validator.stake = String(newTotal);
    storage.set(VALIDATOR_PREFIX + sender, JSON.stringify(validator));
    emit('Bond', { validator: sender, amount: String(amt), totalStake: validator.stake, broadcastUrl: validator.broadcastUrl, antAddr: validator.antAddr || '' });
    return true;
  },

  // Update the broadcastUrl for an already-bonded validator. Required when
  // a validator changes IP (move boxes, NAT reassignment, etc). Only the
  // bonded validator's own address can update their URL.
  //
  // No stake required \u2014 this is metadata only \u2014 but the validator must
  // already be in the bonded set.
  updateBroadcastUrl(broadcastUrl) {
    const sender = String(msg.sender).toLowerCase();
    const existingRaw = storage.get(VALIDATOR_PREFIX + sender);
    assert(existingRaw, 'not a bonded validator');
    assert(isValidBroadcastUrl(broadcastUrl), 'invalid broadcastUrl: must be http(s)://host[:port], \u2264256 chars');
    const validator = JSON.parse(existingRaw);
    const oldUrl = validator.broadcastUrl || '';
    validator.broadcastUrl = String(broadcastUrl);
    storage.set(VALIDATOR_PREFIX + sender, JSON.stringify(validator));
    emit('BroadcastUrlUpdated', { validator: sender, oldUrl, newUrl: validator.broadcastUrl });
    return true;
  },

  // Update the ANT transport address (host:port) for an already-bonded
  // validator, so the baked can dial it as a consensus peer. Metadata only,
  // no stake required, but the validator must already be bonded. Empty clears
  // it (peers then derive from broadcastUrl's host + standard ANT port).
  updateAntAddr(antAddr) {
    const sender = String(msg.sender).toLowerCase();
    const existingRaw = storage.get(VALIDATOR_PREFIX + sender);
    assert(existingRaw, 'not a bonded validator');
    if (antAddr !== undefined && antAddr !== null && antAddr !== '') {
      assert(isValidAntAddr(antAddr), 'invalid antAddr: must be host:port');
    }
    const validator = JSON.parse(existingRaw);
    const oldAddr = validator.antAddr || '';
    validator.antAddr = antAddr ? String(antAddr) : '';
    storage.set(VALIDATOR_PREFIX + sender, JSON.stringify(validator));
    emit('AntAddrUpdated', { validator: sender, oldAddr, newAddr: validator.antAddr });
    return true;
  },

  // Moves \`amount\` from active stake into a pending-withdrawal queue.
  // The funds become withdrawable after UNBONDING_BLOCKS have elapsed.
  // Unbonded stake no longer participates in consensus weighting but is
  // still slashable during the unbonding window.
  unbond(amount) {
    const sender = String(msg.sender).toLowerCase();
    const existingRaw = storage.get(VALIDATOR_PREFIX + sender);
    assert(existingRaw, 'not a bonded validator');
    const validator = JSON.parse(existingRaw);
    const amt = BigInt(amount);
    const current = BigInt(validator.stake);
    assert(amt > 0n, 'unbond amount must be positive');
    assert(current >= amt, 'insufficient bonded stake');

    const remaining = current - amt;
    // Validator can fully exit (remaining = 0) or stay bonded at >= MIN.
    // A partial unbond that would leave stake between 0 and MIN is rejected
    // so the set never drifts into a "below-minimum but not fully exited"
    // gray state.
    if (remaining !== 0n) {
      assert(remaining >= MIN_SELF_BOND_WEI,
        'partial unbond would leave stake below MIN_SELF_BOND; unbond fully or reduce amount');
    }

    validator.stake = String(remaining);
    storage.set(VALIDATOR_PREFIX + sender, JSON.stringify(validator));

    // Enqueue the pending withdrawal.
    const unlockAt = BigInt(chain.blockNumber) + UNBONDING_BLOCKS;
    const queueRaw = storage.get(WITHDRAW_PREFIX + sender) || '[]';
    const queue = JSON.parse(queueRaw);
    queue.push({ amount: String(amt), unlockAt: String(unlockAt) });
    storage.set(WITHDRAW_PREFIX + sender, JSON.stringify(queue));

    emit('Unbond', {
      validator: sender,
      amount: String(amt),
      remainingStake: validator.stake,
      unlockAt: String(unlockAt),
    });
    return true;
  },

  // Claims matured pending withdrawals. Entries whose unlockAt has passed
  // are removed from the queue, the total is sent back to the caller via
  // the chain's transfer() primitive, and the matured amount is returned.
  withdraw() {
    const sender = String(msg.sender).toLowerCase();
    const queueRaw = storage.get(WITHDRAW_PREFIX + sender) || '[]';
    const queue = JSON.parse(queueRaw);
    const now = BigInt(chain.blockNumber);
    let matured = 0n;
    const remaining = [];
    for (let i = 0; i < queue.length; i++) {
      const entry = queue[i];
      if (BigInt(entry.unlockAt) <= now) {
        matured += BigInt(entry.amount);
      } else {
        remaining.push(entry);
      }
    }
    assert(matured > 0n, 'no matured withdrawals');
    storage.set(WITHDRAW_PREFIX + sender, JSON.stringify(remaining));
    // Send the ASE back to the validator's wallet.
    transfer(sender, matured);
    emit('Withdraw', { validator: sender, amount: String(matured) });
    return String(matured);
  },

  // View: pending + matured withdrawal breakdown for a given address.
  getWithdrawable(address) {
    const addr = String(address).toLowerCase();
    const queueRaw = storage.get(WITHDRAW_PREFIX + addr) || '[]';
    const queue = JSON.parse(queueRaw);
    const now = BigInt(chain.blockNumber);
    let matured = 0n;
    let pending = 0n;
    const entries = [];
    for (let i = 0; i < queue.length; i++) {
      const e = queue[i];
      const amt = BigInt(e.amount);
      const unlockAt = BigInt(e.unlockAt);
      if (unlockAt <= now) matured += amt;
      else pending += amt;
      entries.push({ amount: String(amt), unlockAt: String(unlockAt), matured: unlockAt <= now });
    }
    return { matured: String(matured), pending: String(pending), entries };
  },

  // View: current staking parameters.
  getStakingParams() {
    return {
      minSelfBond: String(MIN_SELF_BOND_WEI),
      maxSelfBond: String(MAX_SELF_BOND_WEI),
      unbondingBlocks: String(UNBONDING_BLOCKS),
      pendingBlocks: String(PENDING_BLOCKS),
    };
  },

  getValidator(address) {
    const raw = storage.get(VALIDATOR_PREFIX + String(address).toLowerCase());
    return raw ? JSON.parse(raw) : null;
  },

  getValidators() {
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    const index = JSON.parse(indexRaw);
    const result = [];
    const currentBlock = BigInt(chain.blockNumber);
    for (let i = 0; i < index.length; i++) {
      const addr = index[i];
      const raw = storage.get(VALIDATOR_PREFIX + addr);
      if (raw) {
        const v = JSON.parse(raw);
        if (BigInt(v.stake) > 0n) {
          const bondedAt = BigInt(v.bondedAt || '0');
          const isPending = (currentBlock - bondedAt) < BigInt(PENDING_BLOCKS);
          v.status = isPending ? 'pending' : 'active';
          v.activeAtBlock = String(bondedAt + BigInt(PENDING_BLOCKS));
          result.push(v);
        }
      }
    }
    return result;
  },

  // Returns only validators whose pending period has expired.
  // This is what the consensus engine reads to determine the
  // active validator set for quorum calculations.
  getActiveValidators() {
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    const index = JSON.parse(indexRaw);
    const result = [];
    const currentBlock = BigInt(chain.blockNumber);
    for (let i = 0; i < index.length; i++) {
      const addr = index[i];
      const raw = storage.get(VALIDATOR_PREFIX + addr);
      if (raw) {
        const v = JSON.parse(raw);
        if (BigInt(v.stake) > 0n) {
          const bondedAt = BigInt(v.bondedAt || '0');
          // Genesis validators (bondedAt=0) are always active.
          // Non-genesis validators must wait PENDING_BLOCKS.
          if (bondedAt === 0n || (currentBlock - bondedAt) >= BigInt(PENDING_BLOCKS)) {
            result.push(v);
          }
        }
      }
    }
    return result;
  },

  // \u2500\u2500\u2500 Asentum committee selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
  //
  // submitEpochEntry: a bonded validator declares "I'm online and ready
  // for the next epoch." Must be called during the entry window (last
  // EPOCH_ENTRY_WINDOW blocks of the current epoch) and target the
  // next epoch number. The validator's address (msg.sender) is added
  // to the next epoch's eligible set; the consensus layer uses this
  // set to populate the rotating reserve.
  //
  // Validators in the stable core (top STABLE_CORE_SIZE by stake) do
  // NOT need to call this \u2014 their slot is unconditional. But it's not
  // harmful for them to call; the entry record is simply ignored.
  submitEpochEntry(targetEpoch) {
    const sender = String(msg.sender).toLowerCase();
    const targetEpochNum = BigInt(targetEpoch);
    const currentBlock = BigInt(chain.blockNumber);
    const currentEpoch = currentBlock / EPOCH_BLOCKS;

    // The target must be the next epoch. Allowing arbitrary future
    // epochs would let a validator submit once and stay-eligible forever
    // (bypassing the "must be online recently" intent).
    assert(targetEpochNum === currentEpoch + 1n,
      'submitEpochEntry: targetEpoch must be currentEpoch + 1');

    // Must be inside the entry window \u2014 last EPOCH_ENTRY_WINDOW blocks of
    // the current epoch.
    const epochStart = currentEpoch * EPOCH_BLOCKS;
    const windowOpenAt = epochStart + EPOCH_BLOCKS - EPOCH_ENTRY_WINDOW;
    assert(currentBlock >= windowOpenAt,
      'submitEpochEntry: entry window not yet open for this epoch');

    // Sender must be a currently-bonded validator with positive stake.
    const validatorRaw = storage.get(VALIDATOR_PREFIX + sender);
    assert(validatorRaw, 'submitEpochEntry: not a bonded validator');
    const validator = JSON.parse(validatorRaw);
    assert(BigInt(validator.stake) > 0n,
      'submitEpochEntry: validator has zero stake');

    // #446 re-admission: record the height at which this validator last
    // re-declared readiness. Applies to CORE validators too (whose entry is
    // otherwise ignored for reserve selection) \u2014 so a long-dormant validator
    // that comes back online and re-submits gets a fresh grace window in the
    // dormancy filter (computeFreshEpochEntrants), breaking the "dormant ->
    // can't sign -> stays dormant" trap. Deterministic: chain.blockNumber is
    // identical on every node applying this block.
    validator.enteredAt = String(currentBlock);
    storage.set(VALIDATOR_PREFIX + sender, JSON.stringify(validator));

    // Add to the entry set for the next epoch (idempotent).
    const key = EPOCH_ENTRY_PREFIX + String(targetEpochNum);
    const existingRaw = storage.get(key) || '[]';
    const existing = JSON.parse(existingRaw);
    if (existing.indexOf(sender) < 0) {
      existing.push(sender);
      storage.set(key, JSON.stringify(existing));
      emit('EpochEntry', { validator: sender, targetEpoch: String(targetEpochNum) });
    }
    return true;
  },

  // getEpochCandidates: returns the materials the consensus layer needs
  // to assemble the active committee for \`epochNumber\`:
  //   - core:    top STABLE_CORE_SIZE bonded validators by stake
  //              (sorted desc, ties broken by address asc for determinism)
  //   - reserve: bonded validators NOT in core who submitted an
  //              epoch_entry for this epoch (the eligible pool)
  //
  // The consensus layer is responsible for shuffling \`reserve\` with a
  // deterministic seed and taking the first (COMMITTEE_CAP - core.length)
  // entries to fill the rotating slots. Putting the shuffle outside the
  // contract keeps this method pure data-fetch + sort.
  getEpochCandidates(epochNumber) {
    const epochNum = BigInt(epochNumber);

    // Pull the active set \u2014 bonded, non-pending, positive stake.
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    const index = JSON.parse(indexRaw);
    const currentBlock = BigInt(chain.blockNumber);
    const all = [];
    for (let i = 0; i < index.length; i++) {
      const addr = index[i];
      const raw = storage.get(VALIDATOR_PREFIX + addr);
      if (!raw) continue;
      const v = JSON.parse(raw);
      if (BigInt(v.stake) <= 0n) continue;
      const bondedAt = BigInt(v.bondedAt || '0');
      const isPending = bondedAt !== 0n && (currentBlock - bondedAt) < BigInt(PENDING_BLOCKS);
      if (isPending) continue;
      all.push(v);
    }

    // Sort by stake desc; ties broken by address asc so all nodes agree.
    all.sort(function (a, b) {
      const sa = BigInt(a.stake);
      const sb = BigInt(b.stake);
      if (sb > sa) return 1;
      if (sb < sa) return -1;
      if (a.address < b.address) return -1;
      if (a.address > b.address) return 1;
      return 0;
    });

    // Top STABLE_CORE_SIZE = stable core. Always in committee.
    const core = all.slice(0, STABLE_CORE_SIZE);
    const coreSet = {};
    for (let i = 0; i < core.length; i++) coreSet[core[i].address] = true;

    // Reserve eligibility: bonded + not in core + submitted epoch_entry.
    const entryKey = EPOCH_ENTRY_PREFIX + String(epochNum);
    const entryRaw = storage.get(entryKey) || '[]';
    const entryList = JSON.parse(entryRaw);
    const entrySet = {};
    for (let i = 0; i < entryList.length; i++) entrySet[entryList[i]] = true;

    const reserve = [];
    for (let i = STABLE_CORE_SIZE; i < all.length; i++) {
      const v = all[i];
      if (entrySet[v.address]) reserve.push(v);
    }

    return { core: core, reserve: reserve };
  },

  // View: committee parameters as exposed to clients/UIs.
  getCommitteeParams() {
    return {
      epochBlocks: String(EPOCH_BLOCKS),
      committeeCap: String(COMMITTEE_CAP),
      stableCoreSize: String(STABLE_CORE_SIZE),
      reserveSize: String(RESERVE_SIZE),
      entryWindow: String(EPOCH_ENTRY_WINDOW),
    };
  },

  // View: list addresses that have submitted epoch_entry for an epoch.
  // Useful for diagnostics and the validator client's "did my entry tx land?" check.
  getEpochEntries(epochNumber) {
    const key = EPOCH_ENTRY_PREFIX + String(BigInt(epochNumber));
    const raw = storage.get(key) || '[]';
    return JSON.parse(raw);
  },

  validatorCount() {
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    const index = JSON.parse(indexRaw);
    let count = 0;
    for (let i = 0; i < index.length; i++) {
      const raw = storage.get(VALIDATOR_PREFIX + index[i]);
      if (raw && BigInt(JSON.parse(raw).stake) > 0n) count++;
    }
    return String(count);
  },

  totalBondedStake() {
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    const index = JSON.parse(indexRaw);
    let total = 0n;
    for (let i = 0; i < index.length; i++) {
      const raw = storage.get(VALIDATOR_PREFIX + index[i]);
      if (raw) total += BigInt(JSON.parse(raw).stake);
    }
    return String(total);
  },
});
`.trim();
var SLASHING_CONTRACT_SOURCE = `
const INDEX_KEY = 'tombstone_index';
const TOMBSTONE_PREFIX = 't:';

({
  init() {
    storage.set(INDEX_KEY, JSON.stringify([]));
    emit('Genesis', { tombstoned: '0' });
  },

  // Record a slashing tombstone. Evidence is pre-verified at the apply
  // layer. Also accepts a legacy 2-arg form (offender, evidenceString)
  // for backward compatibility with tests.
  recordSlash(offender, evidenceOrVoteA, voteB) {
    const addr = String(offender).toLowerCase();
    const existing = storage.get(TOMBSTONE_PREFIX + addr);
    if (existing) {
      // Already tombstoned \u2014 no-op.
      return false;
    }
    // Build a compact evidence summary. If we got the 3-arg form,
    // store both vote-bytes lengths as a hint; otherwise treat the
    // second arg as a plain string.
    let evidenceSummary;
    if (typeof voteB === 'string') {
      evidenceSummary = 'voteA=' + String(evidenceOrVoteA).length + ' voteB=' + String(voteB).length + ' bytes';
    } else {
      evidenceSummary = String(evidenceOrVoteA || '');
    }
    const record = {
      address: addr,
      slashedAt: String(chain.blockNumber),
      evidence: evidenceSummary,
    };
    storage.set(TOMBSTONE_PREFIX + addr, JSON.stringify(record));
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    const index = JSON.parse(indexRaw);
    index.push(addr);
    storage.set(INDEX_KEY, JSON.stringify(index));
    emit('Slashed', { validator: addr, slashedAt: record.slashedAt });
    return true;
  },

  isTombstoned(address) {
    const addr = String(address).toLowerCase();
    return storage.get(TOMBSTONE_PREFIX + addr) ? true : false;
  },

  getTombstones() {
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    const index = JSON.parse(indexRaw);
    const result = [];
    for (let i = 0; i < index.length; i++) {
      const raw = storage.get(TOMBSTONE_PREFIX + index[i]);
      if (raw) result.push(JSON.parse(raw));
    }
    return result;
  },

  tombstoneCount() {
    const indexRaw = storage.get(INDEX_KEY) || '[]';
    return String(JSON.parse(indexRaw).length);
  },
});
`.trim();
var GOVERNANCE_CONTRACT_SOURCE = `
const PROPOSAL_PREFIX = 'p:';
const VOTE_PREFIX = 'v:';
const AMENDMENT_PREFIX = 'a:';
const AMENDMENT_VOTE_PREFIX = 'av:';
const LOCKED_PREFIX = 'locked:';
const COUNT_KEY = 'proposalCount';
const AMENDMENT_COUNT_PREFIX = 'ac:';

// ---------------------------------------------------------------
// Governance parameters \u2014 enforced at the chain level.
// These are NOT configurable per-proposal. They are the rules.
// "There is no buts in Asentum, there is only code and truth."
// ---------------------------------------------------------------

// Voting period bounds (in blocks, at 2s/block):
//   MIN: 2160 blocks = ~72 minutes. No flash governance.
//   MAX: 604800 blocks = ~14 days. Proposals can't run forever.
//   DEFAULT: 43200 blocks = ~24 hours.
const MIN_VOTING_PERIOD = 2160;
const MAX_VOTING_PERIOD = 604800;
const DEFAULT_VOTING_PERIOD = 43200;
const AMENDMENT_VOTING_PERIOD = 21600; // ~12 hours

// Minimum quorum: at least this many wei of ASE must participate
// (votesFor + votesAgainst) for the result to be valid. If quorum
// isn't met, execute() marks the proposal as 'no_quorum' instead
// of 'passed' or 'rejected'. Prevents a single small holder from
// passing proposals unilaterally.
//
// 1000 ASE = 1000 * 10^18 wei. Low for testnet; raise for mainnet.
const MIN_QUORUM_WEI = '1000000000000000000000';

// Proposal bond: the proposer must send this much ASE with the
// propose() call. The bond is returned if the proposal reaches
// quorum (whether it passes or fails). Forfeited if it doesn't
// reach quorum \u2014 anti-spam measure.
const PROPOSAL_BOND_WEI = '100000000000000000000'; // 100 ASE

// Execution delay: after voting ends and before the proposal's
// effects take hold, this many blocks must pass. Gives the
// community time to react if something unexpected passes.
const EXECUTION_DELAY = 100; // ~200 seconds on testnet

({
  init() {
    assert(!storage.get('initialized'), 'governance contract already initialized');
    storage.set('initialized', 'true');
    storage.set(COUNT_KEY, '0');
    storage.set('libraryIndex', JSON.stringify([]));
    emit('GovernanceDeployed', { block: String(chain.blockNumber) });
  },

  propose(title, description, category, votingPeriod, libraryName, librarySource) {
    assert(title && title.length > 0, 'title required');
    assert(title.length <= 200, 'title too long (max 200)');
    assert(description && description.length > 0, 'description required');
    assert(description.length <= 10000, 'description too long (max 10000)');

    const validCategories = ['aip', 'parameter', 'library', 'general'];
    const cat = category || 'general';
    assert(validCategories.indexOf(cat) >= 0, 'invalid category: ' + cat);

    // Library proposals must include the library name + source code.
    if (cat === 'library') {
      assert(libraryName && libraryName.length > 0, 'library proposals require a libraryName');
      assert(/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(libraryName), 'libraryName must be a valid JS identifier (e.g. "math", "utils")');
      assert(librarySource && librarySource.length > 0, 'library proposals require librarySource');
      assert(librarySource.length <= 50000, 'library source too large (max 50KB)');
    }

    const period = parseInt(votingPeriod || String(DEFAULT_VOTING_PERIOD));
    assert(period >= MIN_VOTING_PERIOD, 'voting period must be at least ' + MIN_VOTING_PERIOD + ' blocks (~72 minutes)');
    assert(period <= MAX_VOTING_PERIOD, 'voting period must be at most ' + MAX_VOTING_PERIOD + ' blocks (~14 days)');

    // Proposal bond: the proposer must send ASE to create a proposal.
    // Returned if quorum is met; forfeited if not. Anti-spam.
    const bond = BigInt(msg.value);
    assert(bond >= BigInt(PROPOSAL_BOND_WEI), 'must send at least ' + PROPOSAL_BOND_WEI + ' wei (' + String(BigInt(PROPOSAL_BOND_WEI) / 1000000000000000000n) + ' ASE) as proposal bond');

    const id = parseInt(storage.get(COUNT_KEY) || '0');
    const currentBlock = BigInt(chain.blockNumber);

    const proposal = {
      id: id,
      title: title,
      description: description,
      category: cat,
      proposer: msg.sender,
      createdAt: String(currentBlock),
      votingEndsAt: String(currentBlock + BigInt(period)),
      votesFor: '0',
      votesAgainst: '0',
      totalVoteWeight: '0',
      status: 'active',
      amendmentCount: 0,
      // Library-specific fields (null for non-library proposals).
      libraryName: cat === 'library' ? libraryName : null,
      librarySource: cat === 'library' ? librarySource : null,
      // Bond tracking.
      bond: String(bond),
      bondReturned: false,
    };

    storage.set(PROPOSAL_PREFIX + id, JSON.stringify(proposal));
    storage.set(COUNT_KEY, String(id + 1));
    storage.set(AMENDMENT_COUNT_PREFIX + id, '0');

    emit('ProposalCreated', {
      id: String(id),
      title: title,
      category: cat,
      proposer: msg.sender,
    });
    return id;
  },

  vote(proposalId, support) {
    const id = parseInt(proposalId);
    const raw = storage.get(PROPOSAL_PREFIX + id);
    assert(raw, 'proposal not found');
    const proposal = JSON.parse(raw);
    assert(proposal.status === 'active', 'proposal is not active');

    const currentBlock = BigInt(chain.blockNumber);
    assert(currentBlock <= BigInt(proposal.votingEndsAt), 'voting period has ended');

    const weight = BigInt(msg.value);
    assert(weight > 0n, 'must send ASE to vote (vote weight = msg.value)');

    const voteKey = VOTE_PREFIX + id + ':' + msg.sender;
    assert(!storage.get(voteKey), 'already voted on this proposal');

    const sup = support === true || support === 'true' || support === '1' || support === 1;

    storage.set(voteKey, JSON.stringify({
      voter: msg.sender,
      support: sup,
      weight: String(weight),
      block: String(currentBlock),
    }));

    if (sup) {
      proposal.votesFor = String(BigInt(proposal.votesFor) + weight);
    } else {
      proposal.votesAgainst = String(BigInt(proposal.votesAgainst) + weight);
    }
    proposal.totalVoteWeight = String(BigInt(proposal.totalVoteWeight) + weight);
    storage.set(PROPOSAL_PREFIX + id, JSON.stringify(proposal));

    // Track locked tokens for this voter
    const lockedRaw = storage.get(LOCKED_PREFIX + msg.sender) || '0';
    storage.set(LOCKED_PREFIX + msg.sender, String(BigInt(lockedRaw) + weight));

    emit('VoteCast', {
      proposalId: String(id),
      voter: msg.sender,
      support: String(sup),
      weight: String(weight),
    });
    return true;
  },

  proposeAmendment(proposalId, title, description) {
    const id = parseInt(proposalId);
    const raw = storage.get(PROPOSAL_PREFIX + id);
    assert(raw, 'proposal not found');
    const proposal = JSON.parse(raw);
    assert(proposal.status === 'active', 'proposal is not active');

    const currentBlock = BigInt(chain.blockNumber);
    assert(currentBlock <= BigInt(proposal.votingEndsAt), 'voting period has ended');
    assert(title && title.length > 0 && title.length <= 200, 'title required (max 200)');
    assert(description && description.length > 0 && description.length <= 5000, 'description required (max 5000)');

    const amendCountRaw = storage.get(AMENDMENT_COUNT_PREFIX + id) || '0';
    const amendId = parseInt(amendCountRaw);

    const amendment = {
      id: amendId,
      proposalId: id,
      title: title,
      description: description,
      proposer: msg.sender,
      createdAt: String(currentBlock),
      votingEndsAt: String(currentBlock + BigInt(AMENDMENT_VOTING_PERIOD)),
      votesFor: '0',
      votesAgainst: '0',
      status: 'active',
    };

    storage.set(AMENDMENT_PREFIX + id + ':' + amendId, JSON.stringify(amendment));
    storage.set(AMENDMENT_COUNT_PREFIX + id, String(amendId + 1));

    emit('AmendmentProposed', {
      proposalId: String(id),
      amendmentId: String(amendId),
      title: title,
      proposer: msg.sender,
    });
    return amendId;
  },

  voteOnAmendment(proposalId, amendmentId, support) {
    const pId = parseInt(proposalId);
    const aId = parseInt(amendmentId);
    const raw = storage.get(AMENDMENT_PREFIX + pId + ':' + aId);
    assert(raw, 'amendment not found');
    const amendment = JSON.parse(raw);
    assert(amendment.status === 'active', 'amendment is not active');

    const currentBlock = BigInt(chain.blockNumber);
    assert(currentBlock <= BigInt(amendment.votingEndsAt), 'amendment voting has ended');

    const weight = BigInt(msg.value);
    assert(weight > 0n, 'must send ASE to vote');

    const voteKey = AMENDMENT_VOTE_PREFIX + pId + ':' + aId + ':' + msg.sender;
    assert(!storage.get(voteKey), 'already voted on this amendment');

    const sup = support === true || support === 'true' || support === '1' || support === 1;

    storage.set(voteKey, JSON.stringify({
      voter: msg.sender,
      support: sup,
      weight: String(weight),
    }));

    if (sup) {
      amendment.votesFor = String(BigInt(amendment.votesFor) + weight);
    } else {
      amendment.votesAgainst = String(BigInt(amendment.votesAgainst) + weight);
    }
    storage.set(AMENDMENT_PREFIX + pId + ':' + aId, JSON.stringify(amendment));

    const lockedRaw = storage.get(LOCKED_PREFIX + msg.sender) || '0';
    storage.set(LOCKED_PREFIX + msg.sender, String(BigInt(lockedRaw) + weight));

    emit('AmendmentVoteCast', {
      proposalId: String(pId),
      amendmentId: String(aId),
      voter: msg.sender,
      support: String(sup),
      weight: String(weight),
    });
    return true;
  },

  execute(proposalId) {
    const id = parseInt(proposalId);
    const raw = storage.get(PROPOSAL_PREFIX + id);
    assert(raw, 'proposal not found');
    const proposal = JSON.parse(raw);
    assert(proposal.status === 'active', 'proposal already concluded');

    const currentBlock = BigInt(chain.blockNumber);
    const votingEndsAt = BigInt(proposal.votingEndsAt);
    assert(currentBlock > votingEndsAt, 'voting period not yet ended');

    // Execution delay: prevent instant effect after voting ends.
    // The community gets EXECUTION_DELAY blocks to react.
    assert(currentBlock > votingEndsAt + BigInt(EXECUTION_DELAY),
      'execution delay not yet passed (' + EXECUTION_DELAY + ' blocks after voting ends)');

    const votesFor = BigInt(proposal.votesFor);
    const votesAgainst = BigInt(proposal.votesAgainst);
    const totalVotes = votesFor + votesAgainst;

    // Quorum check: enough ASE must have participated for the
    // result to be valid. Without this, 1 ASE could pass anything.
    if (totalVotes < BigInt(MIN_QUORUM_WEI)) {
      proposal.status = 'no_quorum';
      proposal.executedAt = String(currentBlock);
      storage.set(PROPOSAL_PREFIX + id, JSON.stringify(proposal));
      emit('ProposalExecuted', { id: String(id), status: 'no_quorum', totalVotes: String(totalVotes) });
      // Return the proposal bond since the proposer isn't at fault
      // for quorum not being met.
      return 'no_quorum';
    }

    if (votesFor > votesAgainst) {
      proposal.status = 'passed';

      // If this is a library proposal, store the approved library source
      // so the VM can read it and inject it as a contract global.
      if (proposal.category === 'library' && proposal.libraryName && proposal.librarySource) {
        storage.set('lib:' + proposal.libraryName, proposal.librarySource);
        // Update the library index so the VM can enumerate all approved libraries.
        const libIndexRaw = storage.get('libraryIndex') || '[]';
        const libIndex = JSON.parse(libIndexRaw);
        if (libIndex.indexOf(proposal.libraryName) < 0) {
          libIndex.push(proposal.libraryName);
          storage.set('libraryIndex', JSON.stringify(libIndex));
        }
        emit('LibraryApproved', { name: proposal.libraryName, sourceLength: String(proposal.librarySource.length) });
      }
    } else {
      proposal.status = 'rejected';
    }
    proposal.executedAt = String(currentBlock);
    storage.set(PROPOSAL_PREFIX + id, JSON.stringify(proposal));

    // Also finalize any active amendments
    const amendCount = parseInt(storage.get(AMENDMENT_COUNT_PREFIX + id) || '0');
    for (let i = 0; i < amendCount; i++) {
      const aRaw = storage.get(AMENDMENT_PREFIX + id + ':' + i);
      if (aRaw) {
        const amendment = JSON.parse(aRaw);
        if (amendment.status === 'active') {
          const aFor = BigInt(amendment.votesFor);
          const aAgainst = BigInt(amendment.votesAgainst);
          amendment.status = aFor > aAgainst ? 'passed' : 'rejected';
          storage.set(AMENDMENT_PREFIX + id + ':' + i, JSON.stringify(amendment));
        }
      }
    }

    emit('ProposalExecuted', {
      id: String(id),
      status: proposal.status,
      votesFor: proposal.votesFor,
      votesAgainst: proposal.votesAgainst,
    });
    return proposal.status;
  },

  // Voters withdraw their locked ASE after proposals conclude.
  // In a full implementation, this would track per-proposal locks
  // and only release tokens from concluded proposals. For MVP,
  // it releases ALL locked tokens \u2014 users should only withdraw
  // after all their voted proposals have concluded.
  withdraw() {
    const lockedRaw = storage.get(LOCKED_PREFIX + msg.sender) || '0';
    const locked = BigInt(lockedRaw);
    assert(locked > 0n, 'no locked tokens to withdraw');
    // Clear the lock record. The ASE transfer back to the voter is
    // handled by the chain layer's balance delta mechanism.
    storage.set(LOCKED_PREFIX + msg.sender, '0');
    emit('Withdraw', { voter: msg.sender, amount: String(locked) });
    return true;
  },

  getProposal(proposalId) {
    const raw = storage.get(PROPOSAL_PREFIX + parseInt(proposalId));
    return raw ? JSON.parse(raw) : null;
  },

  getProposals() {
    const count = parseInt(storage.get(COUNT_KEY) || '0');
    const result = [];
    for (let i = 0; i < count; i++) {
      const raw = storage.get(PROPOSAL_PREFIX + i);
      if (raw) result.push(JSON.parse(raw));
    }
    return result;
  },

  getAmendments(proposalId) {
    const id = parseInt(proposalId);
    const count = parseInt(storage.get(AMENDMENT_COUNT_PREFIX + id) || '0');
    const result = [];
    for (let i = 0; i < count; i++) {
      const raw = storage.get(AMENDMENT_PREFIX + id + ':' + i);
      if (raw) result.push(JSON.parse(raw));
    }
    return result;
  },

  proposalCount() {
    return storage.get(COUNT_KEY) || '0';
  },

  getLockedBalance(address) {
    return storage.get(LOCKED_PREFIX + String(address).toLowerCase()) || '0';
  },

  // Returns all approved library names + sources. The VM reads this
  // before every contract execution to inject the approved libraries
  // as frozen Compartment globals. Deterministic across all nodes.
  getApprovedLibraries() {
    const indexRaw = storage.get('libraryIndex') || '[]';
    const index = JSON.parse(indexRaw);
    const result = [];
    for (let i = 0; i < index.length; i++) {
      const name = index[i];
      const source = storage.get('lib:' + name);
      if (source) {
        result.push({ name: name, source: source });
      }
    }
    return result;
  },

  getApprovedLibrary(name) {
    const source = storage.get('lib:' + name);
    return source ? { name: name, source: source } : null;
  },
});
`.trim();
var GOVERNANCE_CONTRACT_ADDRESS = new Uint8Array([
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  3
]);
async function deployStakingContractAtGenesis(store, vm, chainId, initialValidators) {
  let contractSource = STAKING_CONTRACT_SOURCE;
  const parsePositiveInt = (v) => {
    if (v === void 0 || !/^[0-9]+$/.test(v)) return null;
    const n = BigInt(v);
    return n > 0n ? n : null;
  };
  const eb = parsePositiveInt(process.env.ASENTUM_EPOCH_BLOCKS);
  const ew = parsePositiveInt(process.env.ASENTUM_EPOCH_ENTRY_WINDOW);
  if (eb !== null) contractSource = contractSource.replace("const EPOCH_BLOCKS = 1440n;", `const EPOCH_BLOCKS = ${eb}n;`);
  if (ew !== null) contractSource = contractSource.replace("const EPOCH_ENTRY_WINDOW = 100n;", `const EPOCH_ENTRY_WINDOW = ${ew}n;`);
  const sourceBytes = new TextEncoder().encode(contractSource);
  const codeHash = blake34(sourceBytes);
  await store.putCode(codeHash, sourceBytes);
  const zeroHash = new Uint8Array(32);
  const placeholder = {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot: zeroHash
  };
  await store.setAccount(STAKING_CONTRACT_ADDRESS, placeholder);
  const snapshot = /* @__PURE__ */ new Map();
  const current = /* @__PURE__ */ new Map();
  const storageAdapter = {
    get(key6) {
      return current.get(key6);
    },
    set(key6, value) {
      current.set(key6, value);
    },
    delete(key6) {
      return current.delete(key6);
    },
    has(key6) {
      return current.has(key6);
    }
  };
  vm.deploy(
    {
      source: contractSource,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex4(STAKING_CONTRACT_ADDRESS),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: storageAdapter,
      gasLimit: 50000000n
    },
    [
      {
        initialValidators: initialValidators.map((v) => ({
          address: v.address.toLowerCase(),
          pubKey: v.pubKey,
          stake: String(v.stake),
          broadcastUrl: v.broadcastUrl,
          antAddr: v.antAddr
        }))
      }
    ]
  );
  for (const [key6, value] of current) {
    if (snapshot.get(key6) !== value) {
      await store.setStorageValue(STAKING_CONTRACT_ADDRESS, key6, value);
    }
  }
  let totalInitialStake = 0n;
  for (const v of initialValidators) {
    totalInitialStake += BigInt(v.stake);
  }
  const storageRoot = await store.computeContractStorageRoot(STAKING_CONTRACT_ADDRESS);
  const finalAccount = {
    balance: totalInitialStake,
    nonce: 0n,
    codeHash,
    storageRoot
  };
  await store.setAccount(STAKING_CONTRACT_ADDRESS, finalAccount);
}
async function deploySlashingContractAtGenesis(store, vm, chainId) {
  const sourceBytes = new TextEncoder().encode(SLASHING_CONTRACT_SOURCE);
  const codeHash = blake34(sourceBytes);
  await store.putCode(codeHash, sourceBytes);
  const zeroHash = new Uint8Array(32);
  const placeholder = {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot: zeroHash
  };
  await store.setAccount(SLASHING_CONTRACT_ADDRESS, placeholder);
  const current = /* @__PURE__ */ new Map();
  const storageAdapter = {
    get(key6) {
      return current.get(key6);
    },
    set(key6, value) {
      current.set(key6, value);
    },
    delete(key6) {
      return current.delete(key6);
    },
    has(key6) {
      return current.has(key6);
    }
  };
  vm.deploy(
    {
      source: SLASHING_CONTRACT_SOURCE,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex4(SLASHING_CONTRACT_ADDRESS),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: storageAdapter,
      gasLimit: 50000000n
    },
    [{}]
  );
  for (const [key6, value] of current) {
    await store.setStorageValue(SLASHING_CONTRACT_ADDRESS, key6, value);
  }
  const storageRoot = await store.computeContractStorageRoot(SLASHING_CONTRACT_ADDRESS);
  const finalAccount = {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot
  };
  await store.setAccount(SLASHING_CONTRACT_ADDRESS, finalAccount);
}
async function deployGovernanceContractAtGenesis(store, vm, chainId) {
  const sourceBytes = new TextEncoder().encode(GOVERNANCE_CONTRACT_SOURCE);
  const codeHash = blake34(sourceBytes);
  await store.putCode(codeHash, sourceBytes);
  const zeroHash = new Uint8Array(32);
  const placeholder = {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot: zeroHash
  };
  await store.setAccount(GOVERNANCE_CONTRACT_ADDRESS, placeholder);
  const current = /* @__PURE__ */ new Map();
  const storageAdapter = {
    get(key6) {
      return current.get(key6);
    },
    set(key6, value) {
      current.set(key6, value);
    },
    delete(key6) {
      return current.delete(key6);
    },
    has(key6) {
      return current.has(key6);
    }
  };
  vm.deploy(
    {
      source: GOVERNANCE_CONTRACT_SOURCE,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex4(GOVERNANCE_CONTRACT_ADDRESS),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: storageAdapter,
      gasLimit: 50000000n
    },
    [{}]
  );
  for (const [key6, value] of current) {
    await store.setStorageValue(GOVERNANCE_CONTRACT_ADDRESS, key6, value);
  }
  const storageRoot = await store.computeContractStorageRoot(GOVERNANCE_CONTRACT_ADDRESS);
  const finalAccount = {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot
  };
  await store.setAccount(GOVERNANCE_CONTRACT_ADDRESS, finalAccount);
}
function bytesToHex4(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}

// src/chain/vm-integration.ts
var ContractLoadNeeded = class extends Error {
  constructor(contractAddress) {
    super(`contract ${contractAddress} needs loading`);
    this.contractAddress = contractAddress;
    this.name = "ContractLoadNeeded";
  }
  contractAddress;
};
var MIN_CONTRACT_GAS = 50000n;
function isDeployTransaction(tx) {
  for (const byte of tx.body.to) {
    if (byte !== 0) return false;
  }
  return true;
}
function isContractAccount(account) {
  if (!account) return false;
  for (const byte of account.codeHash) {
    if (byte !== 0) return true;
  }
  return false;
}
function deriveContractAddress(deployer, nonce) {
  const nonceBytes = new Uint8Array(8);
  let v = nonce;
  for (let i = 7; i >= 0; i--) {
    nonceBytes[i] = Number(v & 0xffn);
    v >>= 8n;
  }
  const input = new Uint8Array(deployer.length + 8);
  input.set(deployer, 0);
  input.set(nonceBytes, deployer.length);
  return blake35(input).slice(0, 20);
}
async function applyDeployTransaction(store, tx, context) {
  const senderAddress2 = addressBytesFromPublicKey3(tx.publicKey);
  const bodyHash = hashTransactionBody(tx.body);
  if (!verify(bodyHash, tx.signature, tx.publicKey)) {
    return { ok: false, reason: "invalid signature" };
  }
  const sender = await store.getOrCreate(senderAddress2);
  if (sender.nonce !== tx.body.nonce) {
    return {
      ok: false,
      reason: `nonce mismatch: expected ${sender.nonce}, got ${tx.body.nonce}`
    };
  }
  if (tx.body.gasLimit < MIN_CONTRACT_GAS) {
    return {
      ok: false,
      reason: `gas limit too low for contract deploy: min ${MIN_CONTRACT_GAS}`
    };
  }
  const maxCost = tx.body.gasLimit * tx.body.maxFeePerGas + tx.body.value;
  if (sender.balance < maxCost) {
    return {
      ok: false,
      reason: `insufficient balance for value+gas: need ${maxCost}, have ${sender.balance}`
    };
  }
  const source = new TextDecoder("utf-8").decode(tx.body.data);
  if (source.length === 0) {
    return { ok: false, reason: "empty contract source" };
  }
  const contractAddress = deriveContractAddress(senderAddress2, sender.nonce);
  const codeHash = blake35(tx.body.data);
  await store.putCode(codeHash, tx.body.data);
  const storageAdapter = await makeStorageAdapter(store, contractAddress);
  let gasUsed = 0n;
  let events = [];
  try {
    const result = context.vm.deploy({
      source,
      context: buildVmContext(senderAddress2, contractAddress, tx, context),
      storage: storageAdapter,
      gasLimit: tx.body.gasLimit
    });
    gasUsed = result.gasUsed;
    events = result.events;
  } catch (err) {
    const feePaid2 = tx.body.gasLimit * tx.body.maxFeePerGas;
    await deductFromSender(store, senderAddress2, sender, feePaid2);
    return {
      ok: true,
      status: 0,
      kind: "deploy",
      senderAddress: senderAddress2,
      contractAddress,
      gasUsed: tx.body.gasLimit,
      feePaid: feePaid2,
      events: [],
      reason: `contract deploy failed: ${err instanceof Error ? err.message : String(err)}`
    };
  }
  await storageAdapter.flush();
  const storageRoot = await store.computeContractStorageRoot(contractAddress);
  const contractAccount = {
    balance: tx.body.value,
    nonce: 0n,
    codeHash,
    storageRoot
  };
  await store.setAccount(contractAddress, contractAccount);
  const feePaid = gasUsed * tx.body.maxFeePerGas;
  const updatedSender = {
    balance: sender.balance - feePaid - tx.body.value,
    nonce: sender.nonce + 1n,
    codeHash: sender.codeHash,
    storageRoot: sender.storageRoot
  };
  await store.setAccount(senderAddress2, updatedSender);
  return {
    ok: true,
    status: 1,
    kind: "deploy",
    senderAddress: senderAddress2,
    contractAddress,
    gasUsed,
    feePaid,
    events
  };
}
async function applyCallTransaction(store, tx, context, recipientAccount) {
  const senderAddress2 = addressBytesFromPublicKey3(tx.publicKey);
  const contractAddress = tx.body.to;
  const bodyHash = hashTransactionBody(tx.body);
  if (!verify(bodyHash, tx.signature, tx.publicKey)) {
    return { ok: false, reason: "invalid signature" };
  }
  const sender = await store.getOrCreate(senderAddress2);
  if (sender.nonce !== tx.body.nonce) {
    return {
      ok: false,
      reason: `nonce mismatch: expected ${sender.nonce}, got ${tx.body.nonce}`
    };
  }
  if (tx.body.gasLimit < MIN_CONTRACT_GAS) {
    return {
      ok: false,
      reason: `gas limit too low for contract call: min ${MIN_CONTRACT_GAS}`
    };
  }
  const maxCost = tx.body.gasLimit * tx.body.maxFeePerGas;
  if (sender.balance < maxCost + tx.body.value) {
    return {
      ok: false,
      reason: `insufficient balance: need ${maxCost + tx.body.value}, have ${sender.balance}`
    };
  }
  let method;
  let args;
  try {
    const decoded = JSON.parse(new TextDecoder("utf-8").decode(tx.body.data));
    if (typeof decoded.method !== "string") {
      return { ok: false, reason: "call data missing string `method` field" };
    }
    method = decoded.method;
    args = Array.isArray(decoded.args) ? decoded.args : [];
  } catch (err) {
    return {
      ok: false,
      reason: `invalid call data: ${err instanceof Error ? err.message : String(err)}`
    };
  }
  if (bytesEqual3(contractAddress, SLASHING_CONTRACT_ADDRESS) && method === "recordSlash") {
    const ev = verifySlashingEvidence(args);
    if (!ev.ok) {
      return { ok: false, reason: `slashing evidence rejected: ${ev.reason}` };
    }
  }
  const sourceBytes = await store.getCode(recipientAccount.codeHash);
  if (!sourceBytes) {
    return { ok: false, reason: "contract code not found for recipient" };
  }
  const source = new TextDecoder("utf-8").decode(sourceBytes);
  const storageAdapter = await makeStorageAdapter(store, contractAddress);
  const govAddr = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3]);
  let txApprovedLibraries;
  try {
    const li = await store.getStorageValue(govAddr, "libraryIndex");
    if (li) {
      const idx = JSON.parse(li);
      if (idx.length > 0) {
        txApprovedLibraries = [];
        for (const n of idx) {
          const s = await store.getStorageValue(govAddr, "lib:" + n);
          if (s) txApprovedLibraries.push({ name: n, source: s });
        }
      }
    }
  } catch {
  }
  const allStorageAdapters = [];
  const callStack = [];
  allStorageAdapters.push({ address: contractAddress, adapter: storageAdapter });
  callStack.push(bytesToHex5(contractAddress).toLowerCase());
  const crossCall = (callerAddr, targetAddr, callMethod, callArgs) => {
    const targetLower = targetAddr.toLowerCase();
    if (callStack.includes(targetLower)) {
      throw new Error(`reentrant call to ${targetAddr} blocked (already on call stack)`);
    }
    const targetAddrBytes = hexToBytes(targetAddr);
    const cachedSource = contractSourceCache.get(targetLower);
    const cachedStorage = contractStorageCache.get(targetLower);
    if (!cachedSource || !cachedStorage) {
      throw new ContractLoadNeeded(targetAddr);
    }
    callStack.push(targetLower);
    try {
      allStorageAdapters.push({ address: targetAddrBytes, adapter: cachedStorage });
      const targetContext = {
        sender: callerAddr,
        contractAddress: targetAddr,
        value: 0n,
        blockNumber: context.blockNumber,
        blockTimestamp: context.blockTimestamp,
        chainId: context.chainId
      };
      const result = context.vm.call(
        {
          source: cachedSource,
          context: targetContext,
          storage: cachedStorage,
          gasLimit: tx.body.gasLimit,
          // shared gas budget
          approvedLibraries: txApprovedLibraries,
          crossCall
        },
        callMethod,
        callArgs
      );
      events.push(...result.events);
      outboundTransfers.push(...result.outboundTransfers);
      return result.returnValue;
    } finally {
      callStack.pop();
    }
  };
  const contractSourceCache = /* @__PURE__ */ new Map();
  const contractStorageCache = /* @__PURE__ */ new Map();
  contractSourceCache.set(bytesToHex5(contractAddress).toLowerCase(), source);
  contractStorageCache.set(bytesToHex5(contractAddress).toLowerCase(), storageAdapter);
  async function preloadContract(addr) {
    const addrLower = addr.toLowerCase();
    if (contractSourceCache.has(addrLower)) return;
    const addrBytes = hexToBytes(addr);
    const acct = await store.getOrCreate(addrBytes);
    if (!acct.codeHash || acct.codeHash.every((b) => b === 0)) return;
    const srcBytes = await store.getCode(acct.codeHash);
    if (!srcBytes) return;
    contractSourceCache.set(addrLower, new TextDecoder("utf-8").decode(srcBytes));
    const sa = await makeStorageAdapter(store, addrBytes);
    contractStorageCache.set(addrLower, sa);
  }
  for (const arg of args) {
    if (typeof arg === "string" && /^0x[0-9a-fA-F]{40}$/.test(arg)) {
      await preloadContract(arg);
    }
  }
  await preloadContract("0x0000000000000000000000000000000000000001");
  await preloadContract("0x0000000000000000000000000000000000000002");
  await preloadContract("0x0000000000000000000000000000000000000003");
  let gasUsed = 0n;
  let events = [];
  let returnValue;
  let outboundTransfers = [];
  const MAX_CROSS_LOAD_RETRIES = 10;
  let vmSuccess = false;
  for (let attempt = 0; attempt <= MAX_CROSS_LOAD_RETRIES; attempt++) {
    events = [];
    outboundTransfers = [];
    allStorageAdapters.length = 0;
    callStack.length = 0;
    const freshStorageAdapter = await makeStorageAdapter(store, contractAddress);
    contractStorageCache.set(bytesToHex5(contractAddress).toLowerCase(), freshStorageAdapter);
    allStorageAdapters.push({ address: contractAddress, adapter: freshStorageAdapter });
    callStack.push(bytesToHex5(contractAddress).toLowerCase());
    try {
      const result = context.vm.call(
        {
          source,
          context: buildVmContext(senderAddress2, contractAddress, tx, context),
          storage: freshStorageAdapter,
          gasLimit: tx.body.gasLimit,
          approvedLibraries: txApprovedLibraries,
          crossCall
        },
        method,
        args
      );
      gasUsed = result.gasUsed;
      events.push(...result.events);
      returnValue = result.returnValue;
      outboundTransfers = result.outboundTransfers;
      vmSuccess = true;
      break;
    } catch (err) {
      const loadNeeded = extractContractLoadNeeded(err);
      if (loadNeeded && attempt < MAX_CROSS_LOAD_RETRIES) {
        await preloadContract(loadNeeded);
        continue;
      }
      const feePaid2 = tx.body.gasLimit * tx.body.maxFeePerGas;
      await deductFromSender(store, senderAddress2, sender, feePaid2);
      return {
        ok: true,
        status: 0,
        kind: "call",
        senderAddress: senderAddress2,
        contractAddress,
        gasUsed: tx.body.gasLimit,
        feePaid: feePaid2,
        events: [],
        reason: `contract call failed: ${err instanceof Error ? err.message : String(err)}`
      };
    }
  }
  if (!vmSuccess) {
    const feePaid2 = tx.body.gasLimit * tx.body.maxFeePerGas;
    await deductFromSender(store, senderAddress2, sender, feePaid2);
    return {
      ok: true,
      status: 0,
      kind: "call",
      senderAddress: senderAddress2,
      contractAddress,
      gasUsed: tx.body.gasLimit,
      feePaid: feePaid2,
      events: [],
      reason: "cross-contract call loading exceeded max retries"
    };
  }
  const contractBalanceAfterInbound = recipientAccount.balance + tx.body.value;
  const totalOutbound = outboundTransfers.reduce((acc, t) => acc + t.amount, 0n);
  if (totalOutbound > contractBalanceAfterInbound) {
    const feePaid2 = gasUsed * tx.body.maxFeePerGas;
    await deductFromSender(store, senderAddress2, sender, feePaid2);
    return {
      ok: true,
      status: 0,
      kind: "call",
      senderAddress: senderAddress2,
      contractAddress,
      gasUsed,
      feePaid: feePaid2,
      events: [],
      reason: `outbound transfers exceed contract balance: need ${totalOutbound}, have ${contractBalanceAfterInbound}`
    };
  }
  for (const { adapter } of allStorageAdapters) {
    await adapter.flush();
  }
  for (const t of outboundTransfers) {
    const toBytes = hexToBytes(t.to);
    const recipient = await store.getOrCreate(toBytes);
    await store.setAccount(toBytes, {
      balance: recipient.balance + t.amount,
      nonce: recipient.nonce,
      codeHash: recipient.codeHash,
      storageRoot: recipient.storageRoot
    });
    events.push({
      name: "Transfer",
      data: { from: bytesToHex5(contractAddress), to: t.to, amount: String(t.amount) }
    });
  }
  const storageRoot = await store.computeContractStorageRoot(contractAddress);
  const updatedContract = {
    balance: contractBalanceAfterInbound - totalOutbound,
    nonce: recipientAccount.nonce,
    codeHash: recipientAccount.codeHash,
    storageRoot
  };
  await store.setAccount(contractAddress, updatedContract);
  const feePaid = gasUsed * tx.body.maxFeePerGas;
  const totalDebit = feePaid + tx.body.value;
  const senderNow = await store.getAccount(senderAddress2) ?? sender;
  const updatedSender = {
    balance: senderNow.balance - totalDebit,
    nonce: sender.nonce + 1n,
    codeHash: senderNow.codeHash,
    storageRoot: senderNow.storageRoot
  };
  await store.setAccount(senderAddress2, updatedSender);
  return {
    ok: true,
    status: 1,
    kind: "call",
    senderAddress: senderAddress2,
    contractAddress,
    gasUsed,
    feePaid,
    events,
    returnValue
  };
}
async function makeStorageAdapter(store, contractAddress) {
  const initialEntries = await store.allStorageForContract(contractAddress);
  const initial = /* @__PURE__ */ new Map();
  for (const { key: key6, value } of initialEntries) {
    initial.set(key6, value);
  }
  const current = new Map(initial);
  const deleted = /* @__PURE__ */ new Set();
  return {
    get(key6) {
      return current.get(key6);
    },
    set(key6, value) {
      current.set(key6, value);
      deleted.delete(key6);
    },
    delete(key6) {
      deleted.add(key6);
      return current.delete(key6);
    },
    has(key6) {
      return current.has(key6);
    },
    // Sorted prefix scan over the working set. Any contract using
    // storage.keys(prefix) (e.g. cron registry, worker registry index)
    // depends on this method existing on both the tx-execution path
    // (this adapter) and the read-only path (readOnlyStorage in
    // runChainCall / runReadOnlyCall). Missing keys() here would make
    // scheduled cron jobs, registry listings, etc. fail non-deterministically
    // depending on which caller path invoked the contract.
    keys(prefix) {
      const p = prefix ?? "";
      const out = [];
      for (const k of current.keys()) {
        if (deleted.has(k)) continue;
        if (p === "" || k.startsWith(p)) out.push(k);
      }
      out.sort();
      return out;
    },
    async flush() {
      for (const [key6, value] of current) {
        if (initial.get(key6) !== value) {
          await store.setStorageValue(contractAddress, key6, value);
        }
      }
      for (const key6 of initial.keys()) {
        if (!current.has(key6)) {
          await store.deleteStorageValue(contractAddress, key6);
        }
      }
    }
  };
}
function buildVmContext(sender, contractAddress, tx, exec) {
  return {
    sender: bytesToHex5(sender),
    contractAddress: bytesToHex5(contractAddress),
    value: tx.body.value,
    blockNumber: exec.blockNumber,
    blockTimestamp: exec.blockTimestamp,
    chainId: exec.chainId
  };
}
async function deductFromSender(store, senderAddress2, sender, amount) {
  const debit = amount > sender.balance ? sender.balance : amount;
  const updated = {
    balance: sender.balance - debit,
    nonce: sender.nonce + 1n,
    codeHash: sender.codeHash,
    storageRoot: sender.storageRoot
  };
  await store.setAccount(senderAddress2, updated);
}
function extractContractLoadNeeded(err) {
  if (err instanceof ContractLoadNeeded) return err.contractAddress;
  if (err instanceof Error && err.cause instanceof ContractLoadNeeded) {
    return err.cause.contractAddress;
  }
  if (err instanceof Error && err.cause instanceof Error) {
    return extractContractLoadNeeded(err.cause);
  }
  if (err instanceof Error && err.name === "ContractLoadNeeded" && "contractAddress" in err) {
    return err.contractAddress;
  }
  return null;
}
function bytesToHex5(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
function hexToBytes(hex2) {
  const clean = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  if (clean.length % 2 !== 0) {
    throw new Error(`hexToBytes: odd-length hex '${hex2}'`);
  }
  const out = new Uint8Array(clean.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}
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;
}
async function runChainCall(options) {
  const {
    store,
    vm,
    contractAddress,
    method,
    args,
    blockNumber,
    blockTimestamp,
    chainId,
    sender,
    gasLimit = 1000000n
  } = options;
  const account = await store.getAccount(contractAddress);
  if (!isContractAccount(account) || !account) {
    return { ok: false, reason: "target is not a contract" };
  }
  const sourceBytes = await store.getCode(account.codeHash);
  if (!sourceBytes) return { ok: false, reason: "contract code not found" };
  const source = new TextDecoder("utf-8").decode(sourceBytes);
  const entries = await store.allStorageForContract(contractAddress);
  const snapshot = /* @__PURE__ */ new Map();
  for (const { key: key6, value } of entries) snapshot.set(key6, value);
  const before = new Map(snapshot);
  const deleted = /* @__PURE__ */ new Set();
  const writeStorage = {
    get(k) {
      return snapshot.get(k);
    },
    set(k, v) {
      snapshot.set(k, v);
      deleted.delete(k);
    },
    delete(k) {
      deleted.add(k);
      return snapshot.delete(k);
    },
    has(k) {
      return snapshot.has(k);
    },
    // @dev sorted prefix scan over the in-memory snapshot. Map insertion
    // order matches allStorageForContract's byte-sorted output, but
    // contract writes during the call may append out-of-order entries,
    // so we sort explicitly, callers (e.g. cron registry's listDue)
    // rely on lex order to break early on the first future timestamp.
    keys(prefix) {
      const p = prefix ?? "";
      const out = [];
      for (const k of snapshot.keys()) {
        if (deleted.has(k)) continue;
        if (p === "" || k.startsWith(p)) out.push(k);
      }
      out.sort();
      return out;
    }
  };
  const govAddress = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3]);
  let approvedLibraries;
  try {
    const libIndexRaw = await store.getStorageValue(govAddress, "libraryIndex");
    if (libIndexRaw) {
      const libIndex = JSON.parse(libIndexRaw);
      if (libIndex.length > 0) {
        approvedLibraries = [];
        for (const name of libIndex) {
          const libSource = await store.getStorageValue(govAddress, "lib:" + name);
          if (libSource) approvedLibraries.push({ name, source: libSource });
        }
      }
    }
  } catch {
  }
  const senderHex = sender ? bytesToHex5(sender) : "0x" + "00".repeat(20);
  try {
    const result = vm.call(
      {
        source,
        context: {
          sender: senderHex,
          contractAddress: bytesToHex5(contractAddress),
          value: 0n,
          blockNumber,
          blockTimestamp,
          chainId
        },
        storage: writeStorage,
        gasLimit,
        approvedLibraries
      },
      method,
      args
    );
    for (const k of deleted) {
      await store.deleteStorageValue(contractAddress, k);
    }
    for (const [k, v] of snapshot) {
      if (before.get(k) !== v) {
        await store.setStorageValue(contractAddress, k, v);
      }
    }
    const newRoot = await store.computeContractStorageRoot(contractAddress);
    await store.setAccount(contractAddress, { ...account, storageRoot: newRoot });
    return {
      ok: true,
      returnValue: result.returnValue,
      events: result.events,
      gasUsed: result.gasUsed
    };
  } catch (err) {
    return {
      ok: false,
      reason: err instanceof Error ? err.message : String(err)
    };
  }
}
async function runReadOnlyCall(options) {
  const {
    store,
    vm,
    contractAddress,
    method,
    args,
    blockNumber,
    blockTimestamp,
    chainId,
    gasLimit = 1000000n
  } = options;
  const account = await store.getAccount(contractAddress);
  if (!isContractAccount(account) || !account) {
    return { ok: false, reason: "target is not a contract" };
  }
  const sourceBytes = await store.getCode(account.codeHash);
  if (!sourceBytes) {
    return { ok: false, reason: "contract code not found" };
  }
  const source = new TextDecoder("utf-8").decode(sourceBytes);
  const entries = await store.allStorageForContract(contractAddress);
  const snapshot = /* @__PURE__ */ new Map();
  for (const { key: key6, value } of entries) snapshot.set(key6, value);
  const readOnlyDeleted = /* @__PURE__ */ new Set();
  const readOnlyStorage = {
    get(k) {
      return snapshot.get(k);
    },
    set(k, v) {
      snapshot.set(k, v);
      readOnlyDeleted.delete(k);
    },
    delete(k) {
      readOnlyDeleted.add(k);
      return snapshot.delete(k);
    },
    has(k) {
      return snapshot.has(k);
    },
    // Sorted prefix scan. cron registry's listDue depends on this being
    // available on the read-only path too — chain calls (like the cron
    // hook running on every block) can't have a divergent surface from
    // tx calls or the same contract source will fail here and succeed
    // there. Same sort semantics as writeStorage.keys().
    keys(prefix) {
      const p = prefix ?? "";
      const out = [];
      for (const k of snapshot.keys()) {
        if (readOnlyDeleted.has(k)) continue;
        if (p === "" || k.startsWith(p)) out.push(k);
      }
      out.sort();
      return out;
    }
  };
  const govAddress = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3]);
  let approvedLibraries;
  try {
    const libIndexRaw = await store.getStorageValue(govAddress, "libraryIndex");
    if (libIndexRaw) {
      const libIndex = JSON.parse(libIndexRaw);
      if (libIndex.length > 0) {
        approvedLibraries = [];
        for (const name of libIndex) {
          const libSource = await store.getStorageValue(govAddress, "lib:" + name);
          if (libSource) {
            approvedLibraries.push({ name, source: libSource });
          }
        }
      }
    }
  } catch {
  }
  try {
    const result = vm.call(
      {
        source,
        context: {
          sender: "0x" + "00".repeat(20),
          contractAddress: bytesToHex5(contractAddress),
          value: 0n,
          blockNumber,
          blockTimestamp,
          chainId
        },
        storage: readOnlyStorage,
        gasLimit,
        approvedLibraries
      },
      method,
      args
    );
    return {
      ok: true,
      returnValue: result.returnValue,
      events: result.events,
      gasUsed: result.gasUsed
    };
  } catch (err) {
    return {
      ok: false,
      reason: err instanceof Error ? err.message : String(err)
    };
  }
}

// src/chain/apply.ts
var TRANSFER_GAS_COST = 21000n;
function hashTransactionBody(body) {
  return blake36(TransactionBodySchema.serialize(body));
}
async function applyTransaction(store, tx, execContext) {
  if (execContext) {
    if (isDeployTransaction(tx)) {
      const r = await applyDeployTransaction(store, tx, execContext);
      if (r.ok) {
        return {
          ok: true,
          status: r.status,
          kind: "deploy",
          senderAddress: r.senderAddress,
          recipientAddress: r.contractAddress,
          gasUsed: r.gasUsed,
          feePaid: r.feePaid,
          events: r.events?.map((e) => ({ name: e.name, data: e.data })) ?? [],
          reason: r.reason
        };
      }
      return { ok: false, reason: r.reason };
    }
    const recipient2 = await store.getAccount(tx.body.to);
    if (isContractAccount(recipient2) && recipient2 !== null) {
      const r = await applyCallTransaction(store, tx, execContext, recipient2);
      if (r.ok) {
        return {
          ok: true,
          status: r.status,
          kind: "call",
          senderAddress: r.senderAddress,
          recipientAddress: r.contractAddress,
          gasUsed: r.gasUsed,
          feePaid: r.feePaid,
          events: r.events?.map((e) => ({ name: e.name, data: e.data })) ?? [],
          returnValue: r.returnValue,
          reason: r.reason
        };
      }
      return { ok: false, reason: r.reason };
    }
  }
  const senderAddress2 = addressBytesFromPublicKey4(tx.publicKey);
  const recipientAddress = tx.body.to;
  const bodyHash = hashTransactionBody(tx.body);
  if (!verify2(bodyHash, tx.signature, tx.publicKey)) {
    return { ok: false, reason: "invalid signature" };
  }
  const sender = await store.getOrCreate(senderAddress2);
  if (sender.nonce !== tx.body.nonce) {
    return {
      ok: false,
      reason: `nonce mismatch: expected ${sender.nonce}, got ${tx.body.nonce}`
    };
  }
  const maxGasCost = tx.body.gasLimit * tx.body.maxFeePerGas;
  const maxRequired = tx.body.value + maxGasCost;
  if (sender.balance < maxRequired) {
    return {
      ok: false,
      reason: `insufficient balance: need ${maxRequired}, have ${sender.balance}`
    };
  }
  const gasUsed = TRANSFER_GAS_COST;
  const feePaid = gasUsed * tx.body.maxFeePerGas;
  const updatedSender = {
    balance: sender.balance - tx.body.value - feePaid,
    nonce: sender.nonce + 1n,
    codeHash: sender.codeHash,
    storageRoot: sender.storageRoot
  };
  await store.setAccount(senderAddress2, updatedSender);
  const recipient = await store.getOrCreate(recipientAddress);
  const updatedRecipient = {
    balance: recipient.balance + tx.body.value,
    nonce: recipient.nonce,
    codeHash: recipient.codeHash,
    storageRoot: recipient.storageRoot
  };
  await store.setAccount(recipientAddress, updatedRecipient);
  return {
    ok: true,
    status: 1,
    kind: "transfer",
    senderAddress: senderAddress2,
    recipientAddress,
    gasUsed,
    feePaid
  };
}

// src/chain/block-builder.ts
import { blake3 as blake38 } from "@asentum/crypto";
import {
  BlockHeaderSchema,
  LastCommitSchema,
  SignedTransactionSchema,
  ZERO_HASH as ZERO_HASH2,
  emptyLastCommit
} from "@asentum/types";

// src/chain/receipts.ts
import { blake3 as blake37 } from "@asentum/crypto";
import {
  ByteListType,
  ByteVectorType,
  ContainerType,
  ListCompositeType,
  UintBigintType,
  UintNumberType
} from "@chainsafe/ssz";
import { ZERO_HASH } from "@asentum/types";
var MAX_EVENT_NAME_BYTES = 256;
var MAX_EVENT_DATA_BYTES = 16384;
var MAX_EVENTS_PER_RECEIPT = 256;
var MAX_RETURN_BYTES = 16384;
var MAX_ERROR_BYTES = 1024;
var RECEIPT_KIND_TRANSFER = 0;
var RECEIPT_KIND_DEPLOY = 1;
var RECEIPT_KIND_CALL = 2;
var ReceiptEventSchema = new ContainerType({
  name: new ByteListType(MAX_EVENT_NAME_BYTES),
  data: new ByteListType(MAX_EVENT_DATA_BYTES)
});
var ReceiptSchema = new ContainerType({
  txHash: new ByteVectorType(32),
  blockNumber: new UintBigintType(8),
  txIndex: new UintNumberType(4),
  sender: new ByteVectorType(20),
  recipient: new ByteVectorType(20),
  kind: new UintNumberType(1),
  success: new UintNumberType(1),
  gasUsed: new UintBigintType(8),
  events: new ListCompositeType(ReceiptEventSchema, MAX_EVENTS_PER_RECEIPT),
  returnValue: new ByteListType(MAX_RETURN_BYTES),
  errorReason: new ByteListType(MAX_ERROR_BYTES)
});
function computeReceiptsRoot(receipts) {
  if (receipts.length === 0) return new Uint8Array(ZERO_HASH);
  const parts = [];
  let total = 0;
  for (const r of receipts) {
    const bytes = ReceiptSchema.serialize(r);
    parts.push(bytes);
    total += bytes.length;
  }
  const combined = new Uint8Array(total);
  let off = 0;
  for (const p of parts) {
    combined.set(p, off);
    off += p.length;
  }
  return blake37(combined);
}
function buildReceipt(txHash3, blockNumber, txIndex, source) {
  const kind = source.kind === "deploy" ? RECEIPT_KIND_DEPLOY : source.kind === "call" ? RECEIPT_KIND_CALL : RECEIPT_KIND_TRANSFER;
  const events = (source.events ?? []).map((e) => ({
    name: new TextEncoder().encode(String(e.name)),
    data: new TextEncoder().encode(
      JSON.stringify(e.data, bigintReplacer) ?? "null"
    )
  }));
  const returnValue = source.returnValue !== void 0 ? new TextEncoder().encode(
    JSON.stringify(source.returnValue, bigintReplacer) ?? "null"
  ) : new Uint8Array(0);
  return {
    txHash: txHash3,
    blockNumber,
    txIndex,
    sender: source.senderAddress,
    recipient: source.recipientAddress,
    kind,
    success: source.success ?? 1,
    gasUsed: source.gasUsed,
    events,
    returnValue,
    errorReason: source.errorReason ? new TextEncoder().encode(source.errorReason) : new Uint8Array(0)
  };
}
function bigintReplacer(_key, value) {
  if (typeof value === "bigint") return value.toString();
  if (value instanceof Uint8Array) {
    let hex2 = "0x";
    for (let i = 0; i < value.length; i++) {
      hex2 += (value[i] ?? 0).toString(16).padStart(2, "0");
    }
    return hex2;
  }
  return value;
}

// src/chain/block-builder.ts
async function buildBlock(options) {
  const { store, parentHeader, mempool, proposer, timestamp, gasLimit, baseFeePerGas } = options;
  const applied = [];
  const receipts = [];
  let gasUsed = 0n;
  const blockNumber = parentHeader.number + 1n;
  const fullExecContext = options.execContext ? {
    ...options.execContext,
    blockNumber,
    blockTimestamp: timestamp
  } : void 0;
  if (options.applyCronHook) {
    await options.applyCronHook(store, fullExecContext);
  }
  for (const tx of mempool) {
    if (gasUsed + tx.body.gasLimit > gasLimit) break;
    const result = await applyTransaction(store, tx, fullExecContext);
    if (result.ok) {
      const txHash3 = blake38(SignedTransactionSchema.serialize(tx));
      const receipt = buildReceipt(txHash3, blockNumber, applied.length, {
        kind: result.kind,
        senderAddress: result.senderAddress,
        recipientAddress: result.recipientAddress,
        gasUsed: result.gasUsed,
        events: result.events,
        returnValue: result.returnValue,
        success: result.status,
        errorReason: result.status === 0 ? result.reason : void 0
      });
      applied.push(tx);
      receipts.push(receipt);
      gasUsed += result.gasUsed;
    } else {
      console.error("[block-builder] tx dropped:", result.reason ?? "unknown reason");
    }
  }
  if (options.applySubsidiesHook) {
    await options.applySubsidiesHook(store);
  }
  const stateRoot = await store.stateRoot();
  const transactionsRoot = computeTransactionsRoot(applied);
  const receiptsRoot = computeReceiptsRoot(receipts);
  const parentHash = blake38(BlockHeaderSchema.serialize(parentHeader));
  const lastCommit = options.lastCommit ?? emptyLastCommit();
  const lastCommitHash = blake38(LastCommitSchema.serialize(lastCommit));
  const header = {
    parentHash,
    stateRoot,
    transactionsRoot,
    receiptsRoot,
    number: blockNumber,
    timestamp,
    gasLimit,
    gasUsed,
    baseFeePerGas,
    proposer,
    lastCommitBitmap: options.lastCommitBitmap,
    lastCommitHash
  };
  return {
    block: { header, transactions: applied, lastCommit },
    receipts
  };
}
function computeTransactionsRoot(txs) {
  if (txs.length === 0) return new Uint8Array(ZERO_HASH2);
  const parts = [];
  let total = 0;
  for (const tx of txs) {
    const bytes = SignedTransactionSchema.serialize(tx);
    parts.push(bytes);
    total += bytes.length;
  }
  const combined = new Uint8Array(total);
  let off = 0;
  for (const p of parts) {
    combined.set(p, off);
    off += p.length;
  }
  return blake38(combined);
}

// src/chain/drift-probe.ts
import { mkdir, writeFile } from "fs/promises";
import { join } from "path";
import {
  SignedTransactionSchema as SignedTransactionSchema2
} from "@asentum/types";
import { blake3 as blake39 } from "@asentum/crypto";
async function dumpDrift(input) {
  try {
    const dir = join(input.dataDir, "drift");
    await mkdir(dir, { recursive: true });
    const accounts = await input.stateStore.allAccounts();
    const dump = {
      schemaVersion: 1,
      capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
      reason: "state_root_mismatch",
      block: {
        number: input.block.header.number.toString(),
        timestamp: input.block.header.timestamp.toString(),
        parentHash: hex(input.block.header.parentHash),
        proposer: hex(input.block.header.proposer),
        txCount: input.block.transactions.length,
        expectedStateRoot: hex(input.expectedStateRoot),
        computedStateRoot: hex(input.computedStateRoot),
        receiptsRoot: hex(input.block.header.receiptsRoot)
      },
      accountCount: accounts.length,
      accounts: accounts.map(({ address, account }) => ({
        address: hex(address),
        balance: account.balance.toString(),
        nonce: account.nonce.toString(),
        codeHash: hex(account.codeHash),
        storageRoot: hex(account.storageRoot)
      })),
      transactions: input.block.transactions.map(
        (tx, i) => summarizeTx(tx, i)
      ),
      subsidyInputs: input.subsidyInputs ?? null,
      receipts: input.rebuiltReceipts.map((r) => ({
        txHash: hex(r.txHash),
        txIndex: r.txIndex,
        sender: hex(r.sender),
        recipient: hex(r.recipient),
        kind: r.kind,
        success: r.success,
        gasUsed: r.gasUsed.toString(),
        eventCount: r.events.length,
        returnValue: hex(r.returnValue),
        errorReason: r.errorReason.length ? new TextDecoder().decode(r.errorReason) : ""
      }))
    };
    const path3 = join(
      dir,
      `block-${input.block.header.number}-${Date.now()}.json`
    );
    await writeFile(path3, JSON.stringify(dump, null, 2));
    return path3;
  } catch (err) {
    console.error("[drift-probe] failed to dump:", err);
    return null;
  }
}
function hex(bytes) {
  let out = "0x";
  for (let i = 0; i < bytes.length; i++) {
    out += bytes[i].toString(16).padStart(2, "0");
  }
  return out;
}
function summarizeTx(tx, index) {
  const txHash3 = blake39(SignedTransactionSchema2.serialize(tx));
  const t = tx;
  const body = t.body ?? {};
  return {
    index,
    txHash: hex(txHash3),
    kind: body.kind,
    sender: body.sender ? hex(body.sender) : null,
    recipient: body.recipient ? hex(body.recipient) : null,
    nonce: body.nonce !== void 0 ? body.nonce.toString() : null,
    value: body.value !== void 0 ? body.value.toString() : null,
    gasLimit: body.gasLimit !== void 0 ? body.gasLimit.toString() : null,
    dataLen: body.data ? body.data.length : 0,
    rawSerialized: hex(SignedTransactionSchema2.serialize(tx))
  };
}

// src/chain/genesis.ts
import {
  ZERO_ADDRESS_BYTES,
  ZERO_HASH as ZERO_HASH3,
  emptyAccount,
  emptyLastCommit as emptyLastCommit2
} from "@asentum/types";

// src/chain/ase-token.ts
import { blake3 as blake310 } from "@asentum/crypto";
import "@asentum/types";
function bytesToHex6(bytes) {
  let h = "0x";
  for (let i = 0; i < bytes.length; i++) h += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  return h;
}
var ASE_TOKEN_ADDRESS = (() => {
  const addr = new Uint8Array(20);
  addr[19] = 7;
  return addr;
})();
var ASE_TOKEN_SOURCE = `
const NAME_KEY = 'meta:name';
const SYMBOL_KEY = 'meta:symbol';
const DECIMALS_KEY = 'meta:decimals';
const TOTAL_SUPPLY_KEY = 'meta:totalSupply';
const BALANCE_PREFIX = 'balance:';
const ALLOWANCE_PREFIX = 'allowance:';
const ZERO = '0x0000000000000000000000000000000000000000';
const CHAIN_CALLER = ZERO;

({
  // One-shot initialiser, called by the chain at genesis with the
  // migrated balance set. Subsequent calls revert.
  init(name, symbol, decimals, initialHolders) {
    assert(!storage.get(NAME_KEY), 'already initialised');
    assert(String(msg.sender).toLowerCase() === CHAIN_CALLER, 'init is chain-only');
    storage.set(NAME_KEY, String(name));
    storage.set(SYMBOL_KEY, String(symbol));
    storage.set(DECIMALS_KEY, String(decimals));

    let total = 0n;
    const holders = initialHolders || [];
    for (let i = 0; i < holders.length; i++) {
      const h = holders[i];
      const addr = String(h.address).toLowerCase();
      const amt = BigInt(h.balance);
      if (amt > 0n) {
        storage.set(BALANCE_PREFIX + addr, amt.toString());
        total = total + amt;
        emit('Transfer', { from: ZERO, to: addr, value: amt.toString() });
      }
    }
    storage.set(TOTAL_SUPPLY_KEY, total.toString());
    return true;
  },

  // ---- ARC-20 standard views ----
  name()        { return storage.get(NAME_KEY) || ''; },
  symbol()      { return storage.get(SYMBOL_KEY) || ''; },
  decimals()    { return storage.get(DECIMALS_KEY) || '18'; },
  totalSupply() { return storage.get(TOTAL_SUPPLY_KEY) || '0'; },
  balanceOf(addr) {
    return storage.get(BALANCE_PREFIX + String(addr).toLowerCase()) || '0';
  },
  allowance(owner, spender) {
    return storage.get(
      ALLOWANCE_PREFIX + String(owner).toLowerCase() + ':' + String(spender).toLowerCase(),
    ) || '0';
  },

  // ---- ARC-20 transfer ----
  transfer(to, amount) {
    return _move(String(msg.sender).toLowerCase(), String(to).toLowerCase(), amount);
  },

  approve(spender, amount) {
    const owner = String(msg.sender).toLowerCase();
    const sp = String(spender).toLowerCase();
    const amt = BigInt(amount);
    assert(amt >= 0n, 'amount must be non-negative');
    storage.set(ALLOWANCE_PREFIX + owner + ':' + sp, amt.toString());
    emit('Approval', { owner, spender: sp, value: amt.toString() });
    return true;
  },

  transferFrom(from, to, amount) {
    const owner = String(from).toLowerCase();
    const spender = String(msg.sender).toLowerCase();
    const recipient = String(to).toLowerCase();
    const allowKey = ALLOWANCE_PREFIX + owner + ':' + spender;
    const allowed = BigInt(storage.get(allowKey) || '0');
    const amt = BigInt(amount);
    assert(allowed >= amt, 'allowance exceeded');
    const newAllow = allowed - amt;
    storage.set(allowKey, newAllow.toString());
    emit('Approval', { owner, spender, value: newAllow.toString() });
    return _move(owner, recipient, amount);
  },

  // ---- Chain-only mint/burn for gas accounting ----
  // The chain calls these from the block apply path to handle
  // validator rewards, fee burns, etc. Restricted to msg.sender = ZERO.
  chainMint(to, amount) {
    assert(String(msg.sender).toLowerCase() === CHAIN_CALLER, 'restricted');
    const recipient = String(to).toLowerCase();
    const amt = BigInt(amount);
    if (amt === 0n) return true;
    const bal = BigInt(storage.get(BALANCE_PREFIX + recipient) || '0');
    storage.set(BALANCE_PREFIX + recipient, (bal + amt).toString());
    const total = BigInt(storage.get(TOTAL_SUPPLY_KEY) || '0');
    storage.set(TOTAL_SUPPLY_KEY, (total + amt).toString());
    emit('Transfer', { from: ZERO, to: recipient, value: amt.toString() });
    return true;
  },

  chainBurn(from, amount) {
    assert(String(msg.sender).toLowerCase() === CHAIN_CALLER, 'restricted');
    const sender = String(from).toLowerCase();
    const amt = BigInt(amount);
    if (amt === 0n) return true;
    const bal = BigInt(storage.get(BALANCE_PREFIX + sender) || '0');
    assert(bal >= amt, 'insufficient balance');
    storage.set(BALANCE_PREFIX + sender, (bal - amt).toString());
    const total = BigInt(storage.get(TOTAL_SUPPLY_KEY) || '0');
    storage.set(TOTAL_SUPPLY_KEY, (total - amt).toString());
    emit('Transfer', { from: sender, to: ZERO, value: amt.toString() });
    return true;
  },
})

function _move(from, to, amount) {
  assert(to !== '0x0000000000000000000000000000000000000000', 'transfer to zero address');
  const amt = BigInt(amount);
  assert(amt >= 0n, 'amount must be non-negative');
  const fromBal = BigInt(storage.get(BALANCE_PREFIX + from) || '0');
  assert(fromBal >= amt, 'insufficient balance');
  const toBal = BigInt(storage.get(BALANCE_PREFIX + to) || '0');
  storage.set(BALANCE_PREFIX + from, (fromBal - amt).toString());
  storage.set(BALANCE_PREFIX + to, (toBal + amt).toString());
  emit('Transfer', { from, to, value: amt.toString() });
  return true;
}
`;
async function installAseToken(opts) {
  const { store, vm, chainId, initialHolders } = opts;
  const sourceBytes = new TextEncoder().encode(ASE_TOKEN_SOURCE);
  const codeHash = blake310(sourceBytes);
  await store.putCode(codeHash, sourceBytes);
  const zeroHash = new Uint8Array(32);
  await store.setAccount(ASE_TOKEN_ADDRESS, {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot: zeroHash
  });
  const inMem = /* @__PURE__ */ new Map();
  vm.deploy(
    {
      source: ASE_TOKEN_SOURCE,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex6(ASE_TOKEN_ADDRESS),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: {
        get: (k) => inMem.get(k),
        set: (k, v) => {
          inMem.set(k, v);
        },
        delete: (k) => inMem.delete(k),
        has: (k) => inMem.has(k)
      },
      gasLimit: 200000000n
    },
    [
      "Asentum",
      "ASE",
      18,
      initialHolders.map((h) => ({ address: h.address.toLowerCase(), balance: h.balance }))
    ]
  );
  for (const [k, v] of inMem) {
    await store.setStorageValue(ASE_TOKEN_ADDRESS, k, v);
  }
  const storageRoot = await store.computeContractStorageRoot(ASE_TOKEN_ADDRESS);
  await store.setAccount(ASE_TOKEN_ADDRESS, {
    balance: 0n,
    nonce: 0n,
    codeHash,
    storageRoot
  });
}

// src/chain/genesis.ts
async function createGenesis(options) {
  const { store, initialAccounts, gasLimit, baseFeePerGas } = options;
  for (const { address, balance } of initialAccounts) {
    await store.setAccount(address, {
      ...emptyAccount(),
      balance
    });
  }
  if (options.stakingVm && options.initialValidators) {
    await deployStakingContractAtGenesis(
      store,
      options.stakingVm,
      options.chainId,
      options.initialValidators
    );
    await deploySlashingContractAtGenesis(
      store,
      options.stakingVm,
      options.chainId
    );
    await deployGovernanceContractAtGenesis(
      store,
      options.stakingVm,
      options.chainId
    );
    await installCronRegistry({
      store,
      vm: options.stakingVm,
      chainId: options.chainId
    });
    await installAseToken({
      store,
      vm: options.stakingVm,
      chainId: options.chainId,
      initialHolders: options.initialAseHolders ?? []
    });
    await installVaultContract({
      store,
      vm: options.stakingVm,
      chainId: options.chainId
    });
    await installWorkerRegistry({
      store,
      vm: options.stakingVm,
      chainId: options.chainId
    });
  }
  const stateRoot = await store.stateRoot();
  const header = {
    parentHash: new Uint8Array(ZERO_HASH3),
    stateRoot,
    transactionsRoot: new Uint8Array(ZERO_HASH3),
    receiptsRoot: new Uint8Array(ZERO_HASH3),
    number: 0n,
    timestamp: 0n,
    gasLimit,
    gasUsed: 0n,
    baseFeePerGas,
    proposer: new Uint8Array(ZERO_ADDRESS_BYTES),
    // Genesis has no prior block, so the bitmap is all-zero. Block 1
    // also gets an all-zero bitmap (no rewards distributed for block 0).
    lastCommitBitmap: new Uint8Array(16),
    // Genesis has no predecessor to commit to. See emptyLastCommit()
    // for the canonical empty-cert value used in block 1 too.
    lastCommitHash: new Uint8Array(32)
  };
  return { header, transactions: [], lastCommit: emptyLastCommit2() };
}

// src/rpc/eth-encoding.ts
import { blake3 as blake311, parseAnyAddress as parseAnyAddress2 } from "@asentum/crypto";
import { BlockHeaderSchema as BlockHeaderSchema2, SignedTransactionSchema as SignedTransactionSchema3 } from "@asentum/types";

// src/rpc/encoding.ts
import { bytesToBech32Address, parseAnyAddress } from "@asentum/crypto";
function bytesToDisplayAddress(bytes) {
  if (process.env.ASENTUM_ADDR_FORMAT === "hex") return bytesToHex7(bytes);
  if (bytes.length !== 20) return bytesToHex7(bytes);
  return bytesToBech32Address(bytes);
}
function hexAddressToDisplay(hex2) {
  if (process.env.ASENTUM_ADDR_FORMAT === "hex") return hex2;
  try {
    return bytesToBech32Address(parseAnyAddress(hex2));
  } catch {
    return hex2;
  }
}
function bytesToHex7(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
function parseAddress(input) {
  return parseAnyAddress(input);
}
function parseHex3(hex2, expectedLength) {
  const clean = hex2.startsWith("0x") || hex2.startsWith("0X") ? hex2.slice(2) : hex2;
  if (clean.length % 2 !== 0) {
    throw new Error("invalid hex: odd number of characters");
  }
  const bytes = new Uint8Array(clean.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    const byte = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(byte)) {
      throw new Error(`invalid hex at position ${i * 2}`);
    }
    bytes[i] = byte;
  }
  if (expectedLength !== void 0 && bytes.length !== expectedLength) {
    throw new Error(
      `invalid hex length: expected ${expectedLength} bytes, got ${bytes.length}`
    );
  }
  return bytes;
}
function encodeBlockHeader(h) {
  return {
    parentHash: bytesToHex7(h.parentHash),
    stateRoot: bytesToHex7(h.stateRoot),
    transactionsRoot: bytesToHex7(h.transactionsRoot),
    receiptsRoot: bytesToHex7(h.receiptsRoot),
    number: h.number.toString(),
    timestamp: h.timestamp.toString(),
    gasLimit: h.gasLimit.toString(),
    gasUsed: h.gasUsed.toString(),
    baseFeePerGas: h.baseFeePerGas.toString(),
    proposer: bytesToDisplayAddress(h.proposer),
    lastCommitBitmap: bytesToHex7(h.lastCommitBitmap)
  };
}
function encodeSignedTransaction(tx) {
  const kind = classifyTransaction(tx);
  let decodedCall;
  if (kind === "call") {
    try {
      const text = new TextDecoder("utf-8").decode(tx.body.data);
      const parsed = JSON.parse(text);
      if (typeof parsed.method === "string") {
        decodedCall = {
          method: parsed.method,
          args: Array.isArray(parsed.args) ? parsed.args : []
        };
      }
    } catch {
    }
  }
  return {
    kind,
    body: {
      chainId: tx.body.chainId.toString(),
      nonce: tx.body.nonce.toString(),
      maxPriorityFeePerGas: tx.body.maxPriorityFeePerGas.toString(),
      maxFeePerGas: tx.body.maxFeePerGas.toString(),
      gasLimit: tx.body.gasLimit.toString(),
      to: bytesToDisplayAddress(tx.body.to),
      value: tx.body.value.toString(),
      dataSize: tx.body.data.length
    },
    call: decodedCall,
    // Omit full pubkey and signature bytes from default JSON display — at
    // ~5 KB per tx they'd dominate responses. Just include sizes.
    publicKeyLength: tx.publicKey.length,
    signatureLength: tx.signature.length
  };
}
function encodeBlock(block, receipts) {
  return {
    header: encodeBlockHeader(block.header),
    transactions: block.transactions.map(encodeSignedTransaction),
    receipts: receipts ? receipts.map(encodeReceipt) : void 0
  };
}
var RECEIPT_KIND_LABELS = ["transfer", "deploy", "call"];
function encodeReceipt(receipt) {
  const decoder = new TextDecoder("utf-8");
  const events = receipt.events.map((e) => {
    const dataStr = decoder.decode(e.data);
    let decodedData = dataStr;
    try {
      decodedData = JSON.parse(dataStr);
    } catch {
    }
    return {
      name: decoder.decode(e.name),
      data: decodedData
    };
  });
  const returnValueStr = receipt.returnValue.length > 0 ? decoder.decode(receipt.returnValue) : null;
  let decodedReturn = returnValueStr;
  if (returnValueStr !== null) {
    try {
      decodedReturn = JSON.parse(returnValueStr);
    } catch {
    }
  }
  return {
    txHash: bytesToHex7(receipt.txHash),
    blockNumber: receipt.blockNumber.toString(),
    txIndex: receipt.txIndex,
    sender: bytesToHex7(receipt.sender),
    recipient: bytesToHex7(receipt.recipient),
    kind: RECEIPT_KIND_LABELS[receipt.kind] ?? "unknown",
    success: receipt.success === 1,
    gasUsed: receipt.gasUsed.toString(),
    events,
    returnValue: decodedReturn
  };
}
function jsonSafe(value) {
  if (value === null || value === void 0) return value;
  if (typeof value === "bigint") return value.toString();
  if (value instanceof Uint8Array) return bytesToHex7(value);
  if (Array.isArray(value)) return value.map(jsonSafe);
  if (typeof value === "object") {
    const out = {};
    for (const [k, v] of Object.entries(value)) {
      out[k] = jsonSafe(v);
    }
    return out;
  }
  return value;
}
function classifyTransaction(tx) {
  const isZeroTo = tx.body.to.every((b) => b === 0);
  if (isZeroTo && tx.body.data.length > 0) return "deploy";
  if (!isZeroTo && tx.body.data.length > 0) {
    try {
      const text = new TextDecoder("utf-8").decode(tx.body.data);
      const parsed = JSON.parse(text);
      if (typeof parsed === "object" && parsed !== null && typeof parsed.method === "string") {
        return "call";
      }
    } catch {
    }
  }
  return "transfer";
}

// src/rpc/eth-encoding.ts
function bytesToHex8(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
function quantityHex(value) {
  const v = typeof value === "number" ? BigInt(value) : value;
  if (v === 0n) return "0x0";
  return "0x" + v.toString(16);
}
function hexToBytes2(hex2) {
  let h = hex2.startsWith("0x") || hex2.startsWith("0X") ? hex2.slice(2) : hex2;
  if (h.length % 2 !== 0) h = "0" + h;
  const out = new Uint8Array(h.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}
function hexToBigint(hex2) {
  if (typeof hex2 !== "string") {
    throw new Error(`expected hex string, got ${typeof hex2}`);
  }
  const h = hex2.startsWith("0x") || hex2.startsWith("0X") ? hex2.slice(2) : hex2;
  if (h === "") return 0n;
  return BigInt("0x" + h);
}
function hexToAddress20(input) {
  try {
    return parseAnyAddress2(input);
  } catch (err) {
    throw new Error(`expected 20-byte address: ${err instanceof Error ? err.message : String(err)}`);
  }
}
function parseBlockParam(param, currentHeight) {
  if (typeof param !== "string") {
    throw new Error(`invalid block parameter: ${JSON.stringify(param)}`);
  }
  if (param === "latest" || param === "safe" || param === "finalized") {
    return { number: currentHeight, tag: param };
  }
  if (param === "earliest") {
    return { number: 0n, tag: "earliest" };
  }
  if (param === "pending") {
    return { number: currentHeight, tag: "pending" };
  }
  try {
    return { number: hexToBigint(param) };
  } catch (err) {
    throw new Error(`invalid block parameter "${param}": ${String(err)}`);
  }
}
var ZERO_HASH_HEX = "0x" + "00".repeat(32);
var EMPTY_UNCLE_HASH = "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347";
var EMPTY_LOGS_BLOOM = "0x" + "00".repeat(256);
function computeBlockHash(header) {
  return blake311(BlockHeaderSchema2.serialize(header));
}
function blockToEthJson(block, includeFullTx) {
  const header = block.header;
  const blockHash = computeBlockHash(header);
  const blockHashHex = bytesToHex8(blockHash);
  const blockNumberHex = quantityHex(header.number);
  const transactions = includeFullTx ? block.transactions.map(
    (tx, i) => txToEthJson(tx, {
      blockHashHex,
      blockNumberHex,
      transactionIndex: i
    })
  ) : block.transactions.map((tx) => bytesToHex8(txHash(tx)));
  const approxSize = BigInt(
    BlockHeaderSchema2.serialize(header).length + block.transactions.reduce(
      (acc, tx) => acc + SignedTransactionSchema3.serialize(tx).length,
      0
    )
  );
  return {
    number: blockNumberHex,
    hash: blockHashHex,
    parentHash: bytesToHex8(header.parentHash),
    // Unused Ethereum fields — stable sentinels so clients don't choke.
    nonce: "0x0000000000000000",
    sha3Uncles: EMPTY_UNCLE_HASH,
    logsBloom: EMPTY_LOGS_BLOOM,
    transactionsRoot: bytesToHex8(header.transactionsRoot),
    stateRoot: bytesToHex8(header.stateRoot),
    receiptsRoot: bytesToHex8(header.receiptsRoot),
    miner: bytesToDisplayAddress(header.proposer),
    difficulty: "0x0",
    totalDifficulty: "0x0",
    extraData: "0x",
    size: quantityHex(approxSize),
    gasLimit: quantityHex(header.gasLimit),
    gasUsed: quantityHex(header.gasUsed),
    timestamp: quantityHex(header.timestamp),
    transactions,
    uncles: [],
    baseFeePerGas: quantityHex(header.baseFeePerGas),
    mixHash: ZERO_HASH_HEX
  };
}
function txHash(tx) {
  return blake311(SignedTransactionSchema3.serialize(tx));
}
function txToEthJson(tx, ctx) {
  const from = addressFromPubKey(tx.publicKey);
  const hash = txHash(tx);
  return {
    blockHash: ctx?.blockHashHex ?? null,
    blockNumber: ctx?.blockNumberHex ?? null,
    transactionIndex: ctx ? quantityHex(ctx.transactionIndex) : null,
    from: bytesToDisplayAddress(from),
    to: isZeroAddress(tx.body.to) ? null : bytesToHex8(tx.body.to),
    gas: quantityHex(tx.body.gasLimit),
    gasPrice: quantityHex(tx.body.maxFeePerGas),
    maxFeePerGas: quantityHex(tx.body.maxFeePerGas),
    maxPriorityFeePerGas: quantityHex(tx.body.maxPriorityFeePerGas),
    hash: bytesToHex8(hash),
    input: bytesToHex8(tx.body.data),
    nonce: quantityHex(tx.body.nonce),
    value: quantityHex(tx.body.value),
    type: "0x2",
    // EIP-1559-shaped
    chainId: quantityHex(tx.body.chainId),
    // Ethereum's signature fields — we use placeholders. The real
    // signature is Dilithium3 and doesn't fit here. Explorers and
    // read-only tooling don't validate these for reads.
    v: "0x0",
    r: ZERO_HASH_HEX,
    s: ZERO_HASH_HEX
  };
}
function receiptEventsToLogs(receipt, blockHashHex) {
  const txHashHex = bytesToHex8(receipt.txHash);
  const blockNumberHex = quantityHex(receipt.blockNumber);
  const txIndexHex = quantityHex(receipt.txIndex);
  const logEmitter = receipt.recipient;
  return (receipt.events ?? []).map((ev, logIndex) => ({
    address: bytesToDisplayAddress(logEmitter),
    topics: [bytesToHex8(blake311(ev.name))],
    data: bytesToHex8(ev.data),
    blockNumber: blockNumberHex,
    transactionHash: txHashHex,
    transactionIndex: txIndexHex,
    blockHash: blockHashHex,
    logIndex: quantityHex(logIndex),
    removed: false
  }));
}
function receiptToEthJson(receipt, blockHashHex) {
  const txHashHex = bytesToHex8(receipt.txHash);
  const blockNumberHex = quantityHex(receipt.blockNumber);
  const txIndexHex = quantityHex(receipt.txIndex);
  const isDeploy = receipt.kind === RECEIPT_KIND_DEPLOY;
  const logs = receiptEventsToLogs(receipt, blockHashHex);
  return {
    transactionHash: txHashHex,
    transactionIndex: txIndexHex,
    blockHash: blockHashHex,
    blockNumber: blockNumberHex,
    from: bytesToDisplayAddress(receipt.sender),
    // For contract creation, Ethereum sets `to: null` and puts the
    // new contract address in `contractAddress`.
    to: isDeploy || isZeroAddress(receipt.recipient) ? null : bytesToHex8(receipt.recipient),
    cumulativeGasUsed: quantityHex(receipt.gasUsed),
    gasUsed: quantityHex(receipt.gasUsed),
    contractAddress: isDeploy ? bytesToHex8(receipt.recipient) : null,
    logs,
    logsBloom: EMPTY_LOGS_BLOOM,
    status: receipt.success === 1 ? "0x1" : "0x0",
    effectiveGasPrice: "0x1",
    // base fee = 1 in the current config
    type: "0x2"
  };
}
function isZeroAddress(addr) {
  for (let i = 0; i < addr.length; i++) {
    if ((addr[i] ?? 0) !== 0) return false;
  }
  return true;
}
function addressFromPubKey(pubkey) {
  return blake311(pubkey).slice(0, 20);
}

// src/node/genesis-validators.ts
var ONE_ASE = 10n ** 18n;
var BAKED_STAKE = 25000000n * ONE_ASE;
var BAKED_GENESIS_VALIDATORS = [
  {
    address: "0x9aad0fb343ec67641b38570ac9ad82c0ec3f870e",
    pubKey: "0x7cf652b37a162561b498babff874284b36bfddb98a79cb30f5c096b762090dff1ed616a44cd892b10f187d6736a8b276412278520503411e729ce176253f599b9c1999e04e9e1e7625f41ac8e611977c2bcc7ca0c11bc1a8981bfb24c098d9b8487bba2d1eb4ccff1af1488117644c6c9d6a2ebac7386d12d62d1a71163a534deca8e0c3e2a1803ddf5055fa29337c848e20c225ff16a376d55a75b4caf2f389c8ad96a7026f59610db2ab55d89c60ba8dda6d09838e69b4f7f75a2940f9bb1a10a53f0a08d6fa4ddded383ae1298e0803964ced3543d71944f1306b7b0c7acfd969c6df6cc0ce782ccac5cf12da76947b718d20d1543b55d5252ca426a89e1d21bd7814eeb1972825d682b641cd4b59d0a016ece465aecaa095440863b511ae5287e6d29eec9cc70cf32fd67e699f80d14d35813ffae08728b14a10434d39af143a504cb0e94233ce46fd9acf67f153a9a2080d83d9bc60e39af76688fa65cd259657ec9527da5ef9500271292c7797cd80f4085bd1f3902012f9da7838ecd0af36f269973436cf812da0b8208dc790e3c2ff54c81081ecc28b236bc7575c67d3ef53079b622305cb9f6d9c536c1e87bf6919c0aaaf321a18c4a87e73525bc23f8cd397488dfd0539e18d642dbd63df4ddb8f267012b4babdc434e41db81730daa6339c823a365d3f1ca83123d00d4f0aeccf6cc1faff52b47da17278bfbdf5670e2a7d244668cdbc1fb32583cacac7cb267e75d81e3c85531d279a547efb838fd52dec7905e30c04a750b51bbf3926f7c4a187cf402288dc77ac7ca6d3b3c19e03811d2c794f1ee96402026c0ea2e06ceabe3c939556f4e8b7e5346aef2c50b2ab83e4b0d1c9118881fd411a6fdc85eb7fa6b5c680c40be204ae50ebb3f81555da60954315a9521d243a3d09803180888b87a08d285d514c7144c8992e3a863dc3f88d08e5463d6c9c3858fd4cbb0b2bf647a188f18d16cf75d6c53f474c53d18b7f8a91a1e1d187e3c9b2f0df9ef009b9485bfbcf21b94ad5cdaf205bb0ca1dde6f41836b672dbfe7ab4b31b54d9359fae5d5714520f0deffc2c20c92400b76ba98a734370ce711adb58e202099b0f2d3466c7a1004e92cea7373cfb29c7e8c125820c0781028d3e87040c42eced0e8db27ed0b478b3e41061cf7e96a7e0377f92535822047c3d5cfc3f64a43c6d43ac0c2e2438c1653a6100785f07b2334c214221298f7f4a609efd92da7fe905b5805ff4239aaceae0ab53c11870fc7421e21ec3533b9bae34940023b269402309f23dea1362068740967ed1e77e6fb2de2501a4fb9af3805918cf0d69e381cdf137b9e9b8c9149e6d11f789f3979a35138b73fba3e98cd0ddeae2977be6a7a180a4090ce247f270dd24c4bbd493f19d2596cecea278d50afb356cb2f055c665d97d34babf72e0b0ce0bb125f2b72a513a88f549316ba977ac5473ca9a32cf999543acca089b00b465d852e72bf24446c8f0251b3d7deb230f8feea90dc905c1412669cdc4841ed37c997f888e97bd9e64de28467cbb817f1104a81986446f628819bef6203258593dffedb2d81d56baf62b7a5ba45c6b1a84ec668d9e94ea3b552a1ef4581a13e7fa84c782a919a95700f42e35b3b7bef62fe8677aabaee1eb19accd513479dbe537f57a2b076a69070d33ae36bcfdfea1dff8128d0b77cfedb59d6bff0858bfac901fa493b1af797373a7fd845af6bf5348a9f6829c87463904e8ef513869fa2537a0fee6d2de6d1c524fde6f2d25d849eaf1b6a0cbe17710f9b4f6e154db11120ce5e246a6284f66815b8eff5b717627fa118d48cf537e4e67454921b65d62565fa5c5ab7e80c458d474fc3654b1c91235df6376fda2d18093c566d8151bb37f8e70c54c030157a67e3b1bb56a19d36378871c161634fb7881715038bc025641b4b3e091d4bdb60d1c57e1a72ac104ffa637d6efee1f15b2855a151e9b2a64205644131e3d5d72cbe39e3469f45f788124cc7757a326d81507843dfb565b1329eb37862343b947782a7f8e3d2a92739ff4d6e8fb1a7ab78a61f227f5aa304cc5e74eb6010c2deeb4c633b37088bd4ad53848860f69040c5784d9091163f63fb9b5127d667923e5bb32bc1622a356a9181b52557656a6466663c6622e596dc4e039585f566d05da117f45df043e3ea33a282eb798557ab7c69379e9e93d778e528ac44c3ad62685150c1d0b1c4602e11197adc426b22fd60a1196c883d64ac616a3324cc3bd4c339cab5822a0f3eba7a59e11c33ddf5c4416916238e04f49867a78ac7797dafad288ec72440e85260f3cf6c503ab608f10bf148a74c0105196439f56b83e9cf77e2106eca603313e69fa33ff544d0743b936c4e143d01f8437d835628df2029048530c351d3084903a2039143ba03d7c25b7b1830b0ac0bdfb87b2c93e66a26e39573132d03fb4c6f604379c4cd5c20372ba496359a2af1f552e8b7fe4bb5c6af01ed9c734831bc212d120b239ec7ad805a4bb2de6b2741f73994ae9569379e693b7daadc8cbb3537e3f6cea970ea6ece31b1d20261ad39a66aa2452b537abda22fe92240586b96b3c358bc13249c8f86af42f13eba13999731d9d7e905f86645a9e321f5546512379f56107534011d8704ef15978e6f742f516754c26632f221380244ad8fb7c1a43a0732d641b59a45e89a3dd6b47f856103154023c94f39908ae064188bdf443f68a04a204400a86b47e43f89cbbccd985f52db2145f7209cc230",
    stake: BAKED_STAKE,
    broadcastUrl: "http://204.168.132.194:8545"
  },
  {
    address: "0x1cb5e65c853b8dfe4c3202a728d84e902c426a79",
    pubKey: "0xcabab219a38603147a9d73d97aefb36c9121d27fa609bd91ad8fc47e4d6b24272630aba3de9917b76ef9e76d3740cd52cdf0ab222275f1c1a0dbb8c1f045674e3dd233b9cb1ec85a294e9b13aa19f24e516924dfbe7fffb938a01034a1f2418b1af4b1c1ebd56a24ddb995ba11a486ea1ef707931e65bee4966379ec3b05fa532f9ecb6c4f871e52d26739b40e3d23f39ab236a93d9d6df89b48fff9df997a51fb5e6348d1f4663b5415832de474ced4557028ca0cfb8e5877d147e289aee3d0a8d1f68ed827111cc01916f9f800efc3ae84f4da30c42c02285ebf95a1ba2341aa4b3e2ee2adedeb866e9bda5c92dbe3e21d6292f1f70e95d2d428b64e529b75e82fe09d72f8481849770970fddedc5fae6ea7e707175956bb4693ad031f08d3204e53833a1e97f53b487e32ff5991f08070186060480ab538ed46e65a7a3989075c1546f21ed080e53702992022b4a290c9cc0f9bb24610eb3cc1b4bab61a0d26f13d3402d4694b85ff50ae2ee94f1faf451e4eab92806905a9976c220758bf78d779bed77e8fa73e86b58b2702b905e08931abe36a9b498570f5993ed1190f7368cdfba91a67696d518feee60e11daa2982a0694cb27dd17d3b2eee6e3f0cfa5ffae99a64425c4fbd4e12116e848c29cae2a7b0d4c4ed34e0c63dc9478124fa8bc14762f7e6fb1f2e5b2e4dc77e013d4fb2daf97fc238993bcb8b6fde3341702cfee449d7a82ee9cc97fbf5781ef9806b97e8c87ca0e9e52f5c4e570416aeb5647bcc6c94f6f5a73e795dae2c5c93f414647ddebe8f514fe4cfa97a3b8dc41b21fd713aa2d68640ce10cf3e2e9a1c53c50832ac23d8df5555e7a108efa7e0b8ebe8f94651c15934fa83b714ef96e0f934df4794cdc24ad16af7d1f4328e1183c2ce02c2cd81743601c571e448d1576c42cba2dfaa05f553155c39c6f55cab9997e0a59441c6b74b232f4297ceea28a4e6ea91d51bdd889cd828a1b91f672e2b37dae07ea40e2bcaabb657b2436ae39c3af4fd92788ad66c77613133b9889da0ad98af8c87916a7d9dcd475b2686bb9d1e84f9e22cbfec180e9f75987dd10163949fa839e1f07f4a26ea1bcd4375d63cb74b66ed70818c78e7e3293612348ed8e155458cec0d8385fb80aa46956455232790c05240dcf256b5290355b8c6c52fad72236bcb9eebfa0eb59d283fb3be146f278ed69d978fbf71fd44fd64f61df7c657ac12ad8e1028f1663c3906019c5d90070123df7c604add6d72936a93bfa1bf957d1cadddee764330ae5624424148c80ee766de85d01e4c6220e869512b356f4c4af3c8edd50eee13387cf5a2ae24a862ed12927fdba7511f22c193dd860d91e6d51bf6b1ac39b3becef45da6ac0539a17354f634a37c04fad7669ff7b9b400a202290561d096b7066debbcdd61c44357f77d6aa1fb1240875c2278f66c1b948237dc1fa3448acaaff7946e1a4da749642da98806cb46dc38eecff81c9283e0e42037dfb3cf88d1a8fa77445558b50ac759e8ffe34abf98c23db8012b502558889f89335df0f6ea816570f4041a9ed5486ee6b7c5c1269f159626c3bff3c7090868f6c44fd79cb16cc7069dc30b81711ea385658880ef13ca06ee2049b3ed9f13563d3f1bc8fa27101bc3f76f4b0c1e877a4c998aba87decc000664c38fff5f2edf85ef1616f17b48b3e558a11226645066356b3034886d2478a8649d761fc1856e475a16baa077b9d22b8189eabb69c78fcbd29f59ac340ba699ed522fdd9218814121ced23955cd254a5b5a86c6fd8ed9bf4a553487aecc0e58ba7b942d91454888dcaa3100e7644004aeda904dbc490543ab665fc28a09f461968e1cfb71e10677d32c7e914fe5c3ec6122342d2fcb3f4c371cf41f40a5e04a7f0d393e24c349c7e764f5a06af438edb2276042ea3d47867aa0632615f34f0bb977f8dd58f3c6143476de69d84ea42b403e3fd3524c6588eb727b76191bb1fbeb5d2cbe014b8047607c046d1f557c21da47780dd4bb32027fb78d09789b7e97e6ad34f4abb4de969dc2ffa76c3a6cf663210952dcf29a134d800de57cc9c8af768ca7b75cb18febaf277e795cdfa2e626bd747acc79de5734ea4261108f2154917b02bc26628147c79d10934039fb2c583a9fd6eb03b12ee2010af9f24a920345aa930240cc043e59e97b8474a807672071db1a614380e3827d17e2c886a978f21799c54c9a9c8cc0cbad6c6ebd9363abdec59f80dd561827a730f0dcf79ed831d49eaf7c1bf7beaf4445c6c640689b21b47f1019b81babe5cdeacef42c0965d166baf75bea4354533792bb73085e99077a3fc060c35fc350e17ee324aa28377b67015881bbda0f7ab4f65fab9b34f0d2f5e3c49da739ee63bde88526055c49b68675a7d5e545dd6effea6222544ca7337478c037958d45e64731172bcc91934c5f8ad732665046ffa97cc9ca79da619434b601233ebb2a69dec55092779d7edbc760b788dd70573c0ecad4813f2fd6d3a02b9506ba18ed61f8963349ab3971c79256e8be6595489b47d93ab19615696205e20d2106d65f197dea232ac4b8e6e9902b8b60b3252efc5e4b2cfe5dc4b65377bb6bae13a7cd205812bb20fefd8cd0c822a9f5d76139f1fcaea2a15977b9d6ad59793cfbb301273433108635eaa6f886ede6bfbc8b998a9cceaece962af22554d78689151afcd869d6dc71e01474b8825c86e0a36d02f2dadf98712462d65d3be8995ff73eddf606b6e427c3f96bacc0629",
    stake: BAKED_STAKE,
    broadcastUrl: "http://178.104.168.222:8545"
  },
  {
    address: "0x72b2064cdd4c02e21577f22baee166e276aff5f2",
    pubKey: "0x290d0be6d48d6ccc444a731d60e864df5521960417786976fae8c3a2d34d147e02db7acd32af25dfc90d29ea7148006b1c6f4ccc7bc95a144096fde37cdcdb3b337610eab0c6c8890e67f0d5019cf5c765eecd3a60699b44df96b81d24f444b1f501076e4d78cd891372812424d0dcd033880a5de2c9608911f529470cc81fd191c8b3bad7bd9e01a69642fb40b5e5efd7a958c5b896b6c3bc0e2389f058b9ca9263a6f51f1168a599cb81c2c76d9a264f454d59dae8bcfde32a0199d8ec542b68a579ba66a48ad89ab1c4bcda35bd0ce29441ef8cc189983609c54993f19fe235a78d26ffa4edf076234fe5ae6cba95015a320bb4a205910e04b537dc5dd8a15ae7de9f7bfefdd19ed78211a4a8d12e7f9536dfb8828f6d1e9723da52a0d508087246ecf3b9738d46fec72332f761a016743d47cc537456d3b88f1a19e3b83c63210bc1895da4efedc707a5381e4919728d3b02810904398dc52a8991d58f041063af4ca3a7610ed32ac39744781b1e7f93eb06e16dc37ab0c8058143ee1dfae061cbbaa5b8aefcb565db86b8219a6a4171310e67a2a897b80917410eb3330aa0ccdd1620ea5a132b1af6fe109a9909ca22a6dca1f63c0ea1da6411de77ebca2c7199b2b8556c5c15e6a9426e57e8ef80a7528c4b8e6db54765bd57f49278cdb816ca3f9e44f2ac36bb03281b8384cc90767a34196646b48b300bae48b5317f4ec9235514ae1f31c2229acc68d16fb80a8b0e1e6781878e99fd05afe8eea2db09cf6d7b73de694dea7c4f3e566161ba75b4362bb837d2824f5e50132bab2d26baad870d723a6a2fe3b6cb9570899d3379c1399a824b67bef3224120f0856b0f1c1a1591172051ee33ed47352123165f1873ba36c0ae9772bd0048a8023bb6384ecb4d2367633ebaa08353f16cb33ac60d9637821cd3cebf3bcaf939a766861a576c4e2df78560140d236283981f036bd44f7a3cd501552441230d267be7dde870a7a8dc33bd52ffb7e14c6b098c17177f9b484801af7b6428ac7e75f6e92c2ffa01ccd5148f97f93762ba6b9c55f7892f122903ce77f065b472ff3f7fc71723f60a7d15ab2640c6e275b5b3b248976197e4ade449b7fb76c1b7e202c445d1b6f879b1739d13eae9466ed3f03f1874cbc29e42552de92df4a877fb110df898bae201a117271f46aee4c27dbee8a2c552ec8c3efaf758e701a60c8a75d77e8df4c3923c625eed00e9867bccb293b9a7cea02d435cb0cc9f29f1349b8b05865bbbe7e63626b1dc67b96a7b2185f7436f94c6a5f446547cdbaa62f20da521ee66ad47724f206cd7e3d274a783857c2c0d26f9c77b5f854fdd6780df006acc1aaa664b0deb7f96b8f1e1dddfc2ae244f0aeb2296eaa575381457ea33c37fccb3eca515b51302eb3c65166e7f71ad5934d12c473488028f3a4e7d031790567440ebc20637284fab9e37b0583b7d198eb1524e060df7c6e78f57b5ae90f92a95863c42b3f7e39ded6132cff1c750c896820bacb827feec23dc102847e51e5ddf762a0d0acfa1e9fe24c96c0eee0fea4b19299ba5679e1068c8116a4b006e3af4a68125c41d73b00fc847e665a69d51f89042a19b7f4e46c9b49be0b34b5920b734534b3baed152671afdee5897e59d59f4a9b3a960eca6fd783a7611c0a11b9fb37660293c25383a08691ae2c91ec49de82b87f50049532ebe4ddb910e0e4fce4feb9f16d728054e614c063faf1d836451d253b54ec278f43cb64bd573c8387c16a07105592f36867bde6493d631b52f749d89c2ab29641d6b8e29ed52450370677a6d8aa34b81f7bd9d9ad0d8254987c711923ecf518108071b5d991da3b4fcbb6e3ba5b393faaa83586e44d26b1e5bd0623f147b59f050b841605c718150d21d8496319defb4039ff034a4ae47a8da943b9c2f1f58e11a3101abb9d4fda1c54f18ee84de499f215c3d63567298b26b53d268e2337e5096b221ec621bad7aa1241c3e095db83b79e155e2395e133c6491917ca46925e2c647d70282e417127262312d47d18cfe85010f00d3111886492ef59b14c8f7d08498f3934a26c846b82cd3090e8580faca91353746201904769cbbc2c162b76d4734a2b98cc8008e04ecac6ab9c995be167e91bd477db6974b7ebd22752967fd7dcdaf031deb816932176a4ee3c72f9db7f68a5275e814667a27532961eba74aa4dc9fb7a0ab213914c6260a4b189fd9f6c9771c35804bc651983d6ccc4d3e5c1d8c13823b7fe1b3a67effdc4095d6f9fa2335b3bf3b7ae98261d70cb5d3522a29e883ac90072ec22d32575520fa719a229c62a4fb087e6f9ff26b4478ed2a02e8af83e0ad9380ef2000ed1d0aec5c0296a48343be441d68a2a9700b5867dab9c70ee532273f64c57e823136cc7c03b86faca023c631e2fd4d9fc6d9bdd28494fbb8adb060fd333cff42aa7e65c8996168376504df2922fd0bdbe0eec1bae8a05f9bc2e76815bb86ee20b4130fbfef7c6d4a82bc9a6549efc632c4a6836de4fe147aa3941f0357cc6416338fb31f92e4613e2ebbfad997a14063e253e5444825ed370723889c579649e216fc85fac366eadbf434a3bc4e10d1cb9de9c55da46e24ced7b7a31237d3b4dffd606b5982b3b9178924078983c2edd1376d13965194461133e032f6c933f60d9ab2a2d8ce00d8820dc919c0dbd343839ddc83d26c21076a8cd0d9f137c00d5b6d88d443db995079977276c556d633eb11ec21480e33d75bd7f1742d62606ef95686",
    stake: BAKED_STAKE,
    broadcastUrl: "http://95.216.209.150:8545"
  },
  {
    address: "0x86f62ed98132374bd8d4c1c6ac39f55f50239453",
    pubKey: "0x62a2bbc60cbb763d1f40bb8015d56b1a1278e9c621364bcebe762c0766fc3acd0e6f70631d3d882197d7cb48ab353c45baeef92c238473c5bafa8b6b91cfc387074e3bf5568c224447835ecc373c79ce69e576942e66b81e8241217cc7643650941465f62b5abbb970cea618a394ec29c2f76895c5f8a6574390fbfd981159283bdc6714961234d849177dcbc4999d934454d7f2f6a6e161d0f1b2bf5d430cd4823d3efee284bb6e9d723839add23ddc05fe31e74ac3897073aa32876184aec31cbaa7eb02150d00cbe00ee82e2e04ff30427a26f9fe4462edad3ce391d8a5c0670317fbfaea9b7409dae0c4008e64ec67bc5361a84fa8b92ffa994c4402de2b543c8a4c5e988812836e8c83125097c2d67db0a8185650f3a24fa8509b28341c14a8d29ce4916474c9590033af22134e7948ea335aa2c926a6166b56007b7db58b0d2f20c0dd89b4929ab061b0dbaa385594b5dcb11f28a52938f64baf0b2aba043e8f7fdba90836679740b0860a04b92b049ed2cdce16c3ef80b08e9fbbc42323371f759736103bd6d0d7faac2306809b4dc4a36cf3421bf45af72f7dec7adf642999a509e76bf86fadccf1ec0777799e6ac2e8dd73f405db6d62acc8a96e7c9d3e649a499870299d7de2049b9121fa13ee5748baf7fa514b5dcf825b3f35f6df7ca505c7798cd5630bab107d6aa4296b042854a7a160c77872f084b060839f2afc693848d6f871b08eda2692cb2b876c3af84214013c4e7f21a8f0d02f5ae257dbe63cd8ef2a8b78a8944a0d654290a0d3495c8702425d8badb9898bcee076b6b9b3693c090d5befa4cb50f5658d2f9be2aafbd8c1b5ffbc1babeb4b10e7d08091f2867c16fd74472dccc2bab8ebe6167f0c57c0f455328d297a5db0ce598c775fdc71c1de1710e064084c5deae2fd6152105b6c10ef20ed521413180f96b102fa1d90e3de82165c62dd3fd6e7222e78e1681917d356b6b757f0e8c2ab9a340df7e103ddf60482f4e51fe3c348a46dcb8faf6d94e134456640710e5deca099a365618b8827824d6629def714d50bcafbdf928156c3d01a6796be6e738b00ab835e204ae584bcf10e5e9c40caa0ef7463883e642a3e4a31a0b08592eb8c73ec90264309f5b2aa38cdcc79e4546a4c310aa9079e1cff6dddb61d04c71924117e3270acbd187c640220a33204a987ec1f5be576e50f4404b1f443bf1ebdc29ceb66793b421fde05aa5f58c37794f40aa0468de138b58a9198f86be6175e886bcfbfce3dbd3cd40c1aeb6cbda364519912bde6b00bd9cafdd77c8d64bc13f0c7bc3c392847ce793449746afd9930010a55516e91415e99833c7c98c4d466564f4bf79ad270c6f92292200752ca21a64d475a8c5eda1525c9221af5bbef3ff53eb82fa3441e8a8e41c13aa879a299457513212b1a8ef296b8996fe22f892e050bfe203c96444711a73a23dc6f3c35caf5ef7074e68681f86bcf6603037b51fb867e399ee1dbb967ec364e2c63539befa59d6d3986fb7c0997f643b6ef2b894ba5f0353a3a260d401d7ba67b34de91dad53c791132814a6fc4a8ca838bdb792f35be879e73c4fc577d8865808d41f2124d6d4a2ca0576c7605a5e1adfb60e504d62c17c81dc615e5a297803df40c7157c2fcc5c090e8f727231d5c4104ae6eb1dc775003661bcfdc2cf0b84c99686844ca762b8ca023dd239ece6180f894b170d6ff8673ef39404ad72707e4f423deafa88d8a9aadf736872dc2c70282db2892a67c0ab58ac3f02f26ad2824282d4964dc642e1eea5fc4eb3e759591216c5fe21743340285edfa6cbf2f0d9a96934fbf435449d599fbe3df7d6e7f4eab3907db6d51174efbf093dc1f3c81233048c6cac078e14a20c10ee009fffa1fae126a1f20b8162b12842f9c80da877fde3c0e9c27b648f08a515b4ac37c99c9ddc98d8ef32cdbba15df429024a4adbdaacd970d70f033538859b42a7ce6f2eb67aeda303113574215e0f4897b153bd7f65f57c649dae49518b88a4eaeddb811a850473ceba733055828c1fe41523a96e28315a669c8a3521ede1750a2b66a14a7283584586d56510c7c869d84c6229b3ad2e5f0bd42861b43cb657829ea46b8f850911ed2bf8ee9a6ec9b37f8dcb67346ebaed7e587a1401c1d92d9763644f0bcaa455d1b7102a05fa85ce23cfb2a13917093d39c5c7e62557e992fddc46b3d805eb7b5c287b11a879b29dc3d7438e8d9beac53505e853409b00850050f2bbca22ba97caebf64d1704a5dbc207f61f05cb7e2040188b37855913d376d2b27dee94a74031553016a2efd60c75eec6fe6a7b5bcc480534f546a5e3228b6138abd67796af7c08d8c8df09ec867903957ba53723aa510b7c0e1e26a05fc286cada5182c10c2cca0d3014107978f9115403342f7a8c74d5c954657c43ddb2dfcc6e2c1d7bc7f07030230dbf5f8c9d147e88c9ed2315aef325ae34f06b04fbc0f1d7f71354a5d4ef0701e6ad3f3ae7feafa4e79835c3a0f8b42ec47716b9ded993720381807fd6defe27b6678d6e160ef34acf03b73fe6ee347c489b2db3f00bf1bd7aef82889be607c0d90c3e37cb760af86adb0b1efc0f16e9ff58e7004842bcf0c0ec59cc3b73e1f6c8092b132af6747a7cb78a850293e5e2b04a5052d49b0b9c3ff3d65f83ce17747b064ca3c53338edfeb52ae39a23f9583eb15d3b24705c83087a22601015614e62c4a52ef85a63316c05cd653afd9e3c3df96c2cbcf1eac687bb9d26c9ed7",
    stake: BAKED_STAKE,
    broadcastUrl: "http://204.168.220.88:8545"
  }
];

// src/chain/genesis-spec.ts
import { readFile, writeFile as writeFile2 } from "fs/promises";
async function saveGenesisSpec(path3, spec) {
  await writeFile2(path3, JSON.stringify(spec, null, 2) + "\n", "utf8");
}
async function loadGenesisSpec(path3) {
  try {
    const raw = await readFile(path3, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === "ENOENT") return null;
    throw err;
  }
}
function toGenesisSpec(opts) {
  return {
    chainId: opts.chainId.toString(),
    gasLimit: opts.gasLimit.toString(),
    baseFeePerGas: opts.baseFeePerGas.toString(),
    initialAccounts: opts.initialAccounts.map((a) => ({
      address: bytesToHex9(a.address),
      balance: a.balance.toString()
    })),
    initialValidators: opts.initialValidators.map((v) => ({
      address: v.address.toLowerCase().startsWith("0x") ? v.address.toLowerCase() : "0x" + v.address.toLowerCase(),
      pubKey: v.pubKey.toLowerCase().startsWith("0x") ? v.pubKey.toLowerCase() : "0x" + v.pubKey.toLowerCase(),
      stake: v.stake.toString()
    })),
    ...opts.initialAseHolders && opts.initialAseHolders.length > 0 ? {
      initialAseHolders: opts.initialAseHolders.map((h) => ({
        address: h.address.toLowerCase(),
        balance: h.balance
      }))
    } : {},
    genesisHash: bytesToHex9(opts.genesisHash),
    genesisStateRoot: bytesToHex9(opts.genesisStateRoot)
  };
}
function fromGenesisSpec(spec) {
  return {
    chainId: BigInt(spec.chainId),
    gasLimit: BigInt(spec.gasLimit),
    baseFeePerGas: BigInt(spec.baseFeePerGas),
    initialAccounts: spec.initialAccounts.map((a) => ({
      address: hexToBytes3(a.address),
      balance: BigInt(a.balance)
    })),
    initialValidators: spec.initialValidators.map((v) => ({
      address: v.address,
      pubKey: v.pubKey,
      stake: BigInt(v.stake)
    })),
    ...spec.initialAseHolders && spec.initialAseHolders.length > 0 ? { initialAseHolders: spec.initialAseHolders.map((h) => ({
      address: h.address.toLowerCase(),
      balance: h.balance
    })) } : {}
  };
}
function bytesToHex9(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
function hexToBytes3(hex2) {
  let h = hex2.startsWith("0x") || hex2.startsWith("0X") ? hex2.slice(2) : hex2;
  if (h.length % 2 !== 0) h = "0" + h;
  const out = new Uint8Array(h.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}

// src/chain/rewards.ts
var REWARDS_POOL_ADDRESS = new Uint8Array([
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  4
]);
var VESTING_POOL_ADDRESS = new Uint8Array([
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  0,
  5
]);
function vestingBlocks() {
  const override = process.env.ASENTUM_VESTING_BLOCKS;
  if (override) {
    try {
      const parsed = BigInt(override);
      if (parsed > 0n) return parsed;
    } catch {
    }
  }
  return 12441600n;
}
var REWARDS_POOL_GENESIS_BALANCE = 150000000n * 10n ** 18n;
var PERIOD_BLOCKS = 3153600n;
var PERIOD_REWARDS_WEI = Object.freeze([
  10000000000000000000n,
  // period 0:  10.0 ASE/block (months 0–6)
  8000000000000000000n,
  // period 1:   8.0 ASE/block (months 6–12)
  6400000000000000000n,
  // period 2:   6.4 ASE/block (months 12–18)
  5100000000000000000n,
  // period 3:   5.1 ASE/block (months 18–24)
  4100000000000000000n,
  // period 4:   4.1 ASE/block (months 24–30)
  3300000000000000000n,
  // period 5:   3.3 ASE/block (months 30–36)
  2600000000000000000n,
  // period 6:   2.6 ASE/block (months 36–42)
  2100000000000000000n,
  // period 7:   2.1 ASE/block (months 42–48)
  1700000000000000000n,
  // period 8:   1.7 ASE/block (months 48–54)
  1300000000000000000n,
  // period 9:   1.3 ASE/block (months 54–60)
  1100000000000000000n,
  // period 10:  1.1 ASE/block (months 60–66)
  900000000000000000n
  // period 11:  0.9 ASE/block (months 66–72)
]);
var PROPOSER_BPS = 500n;
var SIGNER_BPS = 8000n;
var WARM_INACTIVE_BPS = 1500n;
var TOTAL_BPS = 10000n;
var PER_VALIDATOR_CAP_BPS = 1000n;
function blockRewardForHeight(blockNumber) {
  if (blockNumber < 0n) return 0n;
  const period = blockNumber / PERIOD_BLOCKS;
  if (period >= BigInt(PERIOD_REWARDS_WEI.length)) return 0n;
  return PERIOD_REWARDS_WEI[Number(period)] ?? 0n;
}
function distributeBlockReward(opts) {
  const { blockNumber, proposer, signers, warmInactive } = opts;
  const totalReward = blockRewardForHeight(blockNumber);
  const payouts = /* @__PURE__ */ new Map();
  if (totalReward <= 0n) {
    return { blockNumber, totalReward: 0n, payouts, emitted: 0n };
  }
  const proposerSlice = totalReward * PROPOSER_BPS / TOTAL_BPS;
  let signerSlice = totalReward * SIGNER_BPS / TOTAL_BPS;
  let warmSlice = totalReward * WARM_INACTIVE_BPS / TOTAL_BPS;
  if (warmInactive.length === 0) {
    signerSlice += warmSlice;
    warmSlice = 0n;
  }
  addPayout(payouts, proposer, proposerSlice);
  const totalSignerStake = sumStakes(signers);
  if (totalSignerStake > 0n) {
    let cumulative = 0n;
    for (let i = 0; i < signers.length; i++) {
      const s = signers[i];
      const share = i === signers.length - 1 ? signerSlice - cumulative : signerSlice * s.stake / totalSignerStake;
      addPayout(payouts, s.address, share);
      cumulative += share;
    }
  }
  if (warmSlice > 0n) {
    const totalWarmStake = sumStakes(warmInactive);
    if (totalWarmStake > 0n) {
      let cumulative = 0n;
      for (let i = 0; i < warmInactive.length; i++) {
        const w = warmInactive[i];
        const share = i === warmInactive.length - 1 ? warmSlice - cumulative : warmSlice * w.stake / totalWarmStake;
        addPayout(payouts, w.address, share);
        cumulative += share;
      }
    }
  }
  const cap = totalReward * PER_VALIDATOR_CAP_BPS / TOTAL_BPS;
  const activeCommitteeAddrs = /* @__PURE__ */ new Set();
  activeCommitteeAddrs.add(proposer.toLowerCase());
  for (const s of signers) activeCommitteeAddrs.add(s.address.toLowerCase());
  let excess = 0n;
  for (const [addr, amount] of payouts) {
    if (!activeCommitteeAddrs.has(addr)) continue;
    if (amount > cap) {
      excess += amount - cap;
      payouts.set(addr, cap);
    }
  }
  if (excess > 0n) {
    const eligible = [];
    let eligibleStake = 0n;
    for (const s of signers) {
      const current = payouts.get(s.address.toLowerCase()) ?? 0n;
      if (current < cap) {
        eligible.push(s);
        eligibleStake += s.stake;
      }
    }
    if (eligibleStake > 0n) {
      let cumulative = 0n;
      for (let i = 0; i < eligible.length; i++) {
        const v = eligible[i];
        let share = i === eligible.length - 1 ? excess - cumulative : excess * v.stake / eligibleStake;
        const current = payouts.get(v.address.toLowerCase()) ?? 0n;
        const headroom = cap - current;
        if (share > headroom) share = headroom;
        addPayout(payouts, v.address, share);
        cumulative += share;
      }
    }
  }
  let emitted = 0n;
  for (const v of payouts.values()) emitted += v;
  return { blockNumber, totalReward, payouts, emitted };
}
function addPayout(payouts, address, amount) {
  if (amount <= 0n) return;
  const key6 = address.toLowerCase();
  const prev = payouts.get(key6) ?? 0n;
  payouts.set(key6, prev + amount);
}
function sumStakes(participants) {
  let sum = 0n;
  for (const p of participants) sum += p.stake;
  return sum;
}

// src/chain/vesting.ts
var VESTING_KEY_PREFIX = "vest:";
async function listBucketKeysForRecipient(store, recipientHex) {
  const all = await store.allStorageForContract(VESTING_POOL_ADDRESS);
  const prefix = `${VESTING_KEY_PREFIX}${recipientHex.toLowerCase()}:`;
  const out = [];
  for (const entry of all) {
    if (!entry.key.startsWith(prefix)) continue;
    const epochStr = entry.key.slice(prefix.length);
    let epoch;
    try {
      epoch = BigInt(epochStr);
    } catch {
      continue;
    }
    out.push({ key: entry.key, epoch });
  }
  out.sort((a, b) => a.epoch < b.epoch ? -1 : a.epoch > b.epoch ? 1 : 0);
  return out;
}
async function computeAndRecordRelease(store, recipientHexes, currentBlock) {
  const totalVesting = vestingBlocks();
  const released = /* @__PURE__ */ new Map();
  let totalReleased = 0n;
  for (const recipient of recipientHexes) {
    const keys = await listBucketKeysForRecipient(store, recipient);
    let recipientReleased = 0n;
    for (const { key: key6 } of keys) {
      const raw = await store.getStorageValue(VESTING_POOL_ADDRESS, key6);
      if (!raw) continue;
      const bucket = JSON.parse(raw);
      const amount = BigInt(bucket.amount);
      const earnedAt = BigInt(bucket.earnedAt);
      const alreadyReleased = BigInt(bucket.released);
      const elapsed = currentBlock > earnedAt ? currentBlock - earnedAt : 0n;
      const vested = elapsed >= totalVesting ? amount : amount * elapsed / totalVesting;
      const newlyVested = vested > alreadyReleased ? vested - alreadyReleased : 0n;
      if (newlyVested > 0n) {
        recipientReleased += newlyVested;
        bucket.released = vested.toString();
      }
      if (vested >= amount && BigInt(bucket.released) >= amount) {
        await store.deleteStorageValue(VESTING_POOL_ADDRESS, key6);
      } else if (newlyVested > 0n) {
        await store.setStorageValue(VESTING_POOL_ADDRESS, key6, JSON.stringify(bucket));
      }
    }
    if (recipientReleased > 0n) {
      released.set(recipient.toLowerCase(), recipientReleased);
      totalReleased += recipientReleased;
    }
  }
  return { released, totalReleased };
}

// src/chain/last-commit-bitmap.ts
import { LAST_COMMIT_BITMAP_BYTES, LAST_COMMIT_BITMAP_SLOTS } from "@asentum/types";
function emptyLastCommitBitmap() {
  return new Uint8Array(LAST_COMMIT_BITMAP_BYTES);
}
function buildLastCommitBitmap(committeeAddresses, signerAddresses) {
  if (committeeAddresses.length > LAST_COMMIT_BITMAP_SLOTS) {
    throw new Error(
      `lastCommitBitmap: committee size ${committeeAddresses.length} exceeds bitmap capacity ${LAST_COMMIT_BITMAP_SLOTS}`
    );
  }
  const bitmap = new Uint8Array(LAST_COMMIT_BITMAP_BYTES);
  const signerSet = new Set(signerAddresses.map((a) => a.toLowerCase()));
  for (let i = 0; i < committeeAddresses.length; i++) {
    if (signerSet.has(committeeAddresses[i].toLowerCase())) {
      const byteIdx = i >> 3;
      const bitIdx = 7 - (i & 7);
      bitmap[byteIdx] = (bitmap[byteIdx] ?? 0) | 1 << bitIdx;
    }
  }
  return bitmap;
}
function decodeLastCommitBitmap(bitmap, committeeAddresses) {
  if (bitmap.length !== LAST_COMMIT_BITMAP_BYTES) {
    throw new Error(`lastCommitBitmap: expected ${LAST_COMMIT_BITMAP_BYTES} bytes, got ${bitmap.length}`);
  }
  const signers = [];
  for (let i = 0; i < committeeAddresses.length; i++) {
    const byteIdx = i >> 3;
    const bitIdx = 7 - (i & 7);
    if ((bitmap[byteIdx] ?? 0) >> bitIdx & 1) {
      signers.push(committeeAddresses[i].toLowerCase());
    }
  }
  return signers;
}

// src/chain/tx-builder.ts
import { sign } from "@asentum/crypto";
function buildAndSignTransfer(options) {
  const body = {
    chainId: options.chainId,
    nonce: options.nonce,
    maxPriorityFeePerGas: options.maxPriorityFeePerGas,
    maxFeePerGas: options.maxFeePerGas,
    gasLimit: options.gasLimit,
    to: options.to,
    value: options.value,
    data: options.data ?? new Uint8Array(0)
  };
  const bodyHash = hashTransactionBody(body);
  const signature = sign(bodyHash, options.senderKeypair.secretKey);
  return {
    body,
    publicKey: options.senderKeypair.publicKey,
    signature
  };
}
function buildAndSignCall(options) {
  const payload = JSON.stringify({ method: options.method, args: options.args });
  const body = {
    chainId: options.chainId,
    nonce: options.nonce,
    maxPriorityFeePerGas: 0n,
    maxFeePerGas: 1n,
    gasLimit: options.gasLimit,
    to: options.to,
    value: options.value,
    data: new TextEncoder().encode(payload)
  };
  const bodyHash = hashTransactionBody(body);
  const signature = sign(bodyHash, options.senderKeypair.secretKey);
  return {
    body,
    publicKey: options.senderKeypair.publicKey,
    signature
  };
}

// src/consensus/round.ts
import { blake3 as blake312 } from "@asentum/crypto";
var QUORUM_BPS = (() => {
  const raw = process.env.ASENTUM_QUORUM_BPS;
  if (!raw) return 6667n;
  let n;
  try {
    n = BigInt(raw);
  } catch {
    throw new Error(`ASENTUM_QUORUM_BPS must be int, got ${raw}`);
  }
  if (n < 5000n || n > 6700n) {
    throw new Error(`ASENTUM_QUORUM_BPS must be in [5000, 6700], got ${raw}`);
  }
  return n;
})();
function quorumThresholdStake(validators) {
  const total = totalBondedStake(validators);
  if (total <= 0n) return 0n;
  return QUORUM_BPS * total / 10000n + 1n;
}
function proposerForRound(validators, height, round) {
  if (validators.length === 0) return null;
  const sorted = [...validators].sort(
    (a, b) => a.address < b.address ? -1 : a.address > b.address ? 1 : 0
  );
  const stakes = sorted.map((v) => {
    try {
      const s = BigInt(v.stake);
      return s > 0n ? s : 0n;
    } catch {
      return 0n;
    }
  });
  const total = stakes.reduce((a, b) => a + b, 0n);
  if (total === 0n) {
    const n = BigInt(sorted.length);
    const idx = Number(((height + round) % n + n) % n);
    return sorted[idx] ?? null;
  }
  const input = new Uint8Array(32);
  writeUint128BE(input, 0, height);
  writeUint128BE(input, 16, round);
  const digest = blake312(input);
  const drawn = readUint128BE(digest, 0);
  const offset = drawn % total;
  let cum = 0n;
  for (let i = 0; i < sorted.length; i++) {
    cum += stakes[i] ?? 0n;
    if (offset < cum) {
      return sorted[i] ?? null;
    }
  }
  return sorted[sorted.length - 1] ?? null;
}
function writeUint128BE(buf, offset, value) {
  let v = value & (1n << 128n) - 1n;
  for (let i = 15; i >= 0; i--) {
    buf[offset + i] = Number(v & 0xffn);
    v >>= 8n;
  }
}
function readUint128BE(buf, offset) {
  let v = 0n;
  for (let i = 0; i < 16; i++) {
    v = v << 8n | BigInt(buf[offset + i] ?? 0);
  }
  return v;
}
function totalBondedStake(validators) {
  let sum = 0n;
  for (const v of validators) {
    try {
      sum += BigInt(v.stake);
    } catch {
    }
  }
  return sum;
}

// src/consensus/index.ts
init_sign();

// src/consensus/engine.ts
import {
  VOTE_KIND_PRECOMMIT as VOTE_KIND_PRECOMMIT2,
  VOTE_KIND_PREVOTE as VOTE_KIND_PREVOTE2
} from "@asentum/types";
init_sign();
var ConsensusEngine = class _ConsensusEngine {
  height = 0n;
  round = 0n;
  /**
   * Highest height we have observed in any incoming vote. Tracked
   * regardless of whether the vote was accepted (height-mismatch votes
   * still update this). Used by the receiver-side behind-detect: if
   * peers are voting on heights far above our local head, we are
   * lagging and should hand off to block-sync instead of running the
   * BFT cycle on stale proposals queued in the ANT receive buffer.
   */
  maxSeenVoteHeight = 0n;
  phase = "propose";
  validators = [];
  validatorSet = /* @__PURE__ */ new Set();
  /**
   * Proposer rotation set — the BONDED validators (active + inactiveBonded
   * with their on-chain stakes). Used ONLY by `currentProposer()` for
   * deterministic stake-weighted leader election.
   *
   * Why this is separate from `this.validators` (Phase B's active filter):
   * `this.validators` is computed from each node's locally-observed
   * `lastVotedHeight` map and so differs subtly between nodes due to
   * network jitter. Driving proposer rotation off it makes nodes pick
   * different proposers for the same (h, r) → faction split → wedge
   * (observed prod h=1060, 2026-06-06). Bonded is chain-deterministic, so
   * every node always agrees on who's allowed to propose. Phase B
   * continues to drive QUORUM threshold (denominator scales with
   * observed-active stake) but never proposer selection.
   */
  proposerRotationSet = [];
  /**
   * Per-validator stake lookup, populated each `startHeight`. Used to
   * compute stake-weighted bucket totals without re-parsing strings on
   * every vote.
   */
  validatorStake = /* @__PURE__ */ new Map();
  prevotes = /* @__PURE__ */ new Map();
  precommits = /* @__PURE__ */ new Map();
  committedBlockHash = null;
  chainId;
  quorumListeners = /* @__PURE__ */ new Set();
  doubleSignListeners = /* @__PURE__ */ new Set();
  polkaCommitListeners = /* @__PURE__ */ new Set();
  /**
   * Per-(kind,validator) record of the first blockHash signed at the
   * current (height, round). A second vote for a different hash is a
   * double-sign. Cleared on startHeight + advanceRound.
   */
  prevoteByValidator = /* @__PURE__ */ new Map();
  precommitByValidator = /* @__PURE__ */ new Map();
  /** Votes received at rounds strictly greater than `this.round`, keyed
   * by stringified round number. Drained in `advanceRound` when the new
   * round matches a buffered key. */
  pendingFutureVotes = /* @__PURE__ */ new Map();
  /** Vote-history ring (#267): retains snapshots of per-(h,r,kind) vote sets
   * for the last `voteHistoryDepth` heights so the relay can serve historical
   * round votes to laggy operators whose local engine has not yet reached
   * the round where the chain is currently voting. Without this, `/consensus/votes`
   * always returns {available:false} to an operator that arrives even slightly
   * late, and that operator can never see peer prevotes to reach quorum locally. */
  static voteHistoryDepth = 10;
  voteHistory = /* @__PURE__ */ new Map();
  voteHistoryHeightOrder = [];
  /** Dedup set for stale-vote rejection logs. Repeated stale-vote spam
   * pinned the event loop; key is (validator, round, blockHash). */
  staleVoteLogSeen = /* @__PURE__ */ new Set();
  /**
   * #276 — Round-stale tolerance window. Votes from rounds within K of
   * `this.round` (so `this.round - K <= vote.round < this.round`) are NOT
   * rejected as stale. They get bucketed here, keyed by stringified round
   * → (dedupKey → vote), and used to attempt commit-from-precommit-polka
   * for slightly-older rounds.
   *
   * Without this, a cluster whose nodes drift onto different rounds — e.g.
   * the 2026-06-15 prod stall at h=22912 where the 4 baked were at rounds
   * 765–781 simultaneously — never converges, because each node's stale
   * round-cutoff is exactly its own current round.
   *
   * Pruned in advanceRound (drops entries older than this.round - K) and
   * fully cleared in startHeight.
   */
  // K=32 covers the prod 2026-06-15 stall shape (max gap observed: 16
  // rounds across 4 baked). The polka pre-check + signature verification
  // still gate any commit, so the tolerance window only affects WHEN we
  // accept, not WHAT we accept. Bumping K further is cheap and safe;
  // the limiting factor is voteHistoryDepth (currently 10 heights).
  //
  static STALE_TOLERANCE_K = 32n;
  /** One-shot guard for the devnet round-skew injection (see startHeight). */
  static devSkewApplied = false;
  staleVoteBacklog = /* @__PURE__ */ new Map();
  /** Recently-detected double-sign evidence at this engine. Drained by the slashing pipeline. */
  recentEvidence = [];
  /**
   * The (height, blockHash) of the block this engine just committed. Used
   * to accept "late precommits" for that block which arrive AFTER startHeight
   * has advanced the engine to the next height. Without this, late precommits
   * from validators with worse network/CPU latency get rejected as
   * height-stale and never make it into the next block's lastCommitBitmap.
   * That structurally excludes the slowest validator from rewards each
   * block and rotates among validators per block. See:
   * `lateCommittedPrecommits`.
   */
  lastCommittedHeight = null;
  lastCommittedHashHex = null;
  /**
   * Wall-clock milliseconds at the moment this engine transitioned into
   * `phase='commit'` for `lastCommittedHeight`. Used by the producer to
   * implement the #341 Phase 2 grace window: delay bitmap-freeze by up
   * to `GRACE_MS` after commit so more late precommits can arrive and
   * be included in the next block's `lastCommitBitmap`.
   *
   * Producer-only, never affects consensus state or deterministic
   * output. Zero when we've never committed.
   */
  lastCommittedAtWallMs = 0;
  /**
   * The exact precommit votes that formed the ≥2/3 quorum on the block
   * this engine committed at `lastCommittedHeight`. Captured at commit
   * time BEFORE `resetConsensusForNextHeight` wipes the round buckets.
   *
   * Read by the next block's producer via `getCommittedPrecommits()` to
   * build the on-chain `LastCommit` cert. Byte-identical LastCommit ⇒
   * byte-identical block ⇒ single canonical head. See
   * notes/last-commit-cert-design-2026-07-01.md and task #315.
   */
  lastCommittedPrecommitVotes = [];
  /**
   * Legacy single-slot late-vote array. Retained only for backward
   * compatibility with existing call sites that hold it directly; the
   * canonical store is now `lateCommittedByHeight` (K-slot, per #341).
   * Kept as a synchronised alias of the current-height slot so nothing
   * that reads `.length` or iterates has to change.
   */
  lastCommittedLateVotes = [];
  /** Round at which `lastCommittedHashHex` finalized (part of the sig domain). */
  lastCommittedRound = 0n;
  /**
   * Tendermint locked-prevote state (paper §4.2).
   *
   * Once we observe a prevote quorum ("polka") for a block at (height, round),
   * we lock on that (round, hash). Subsequent rounds at the same height MUST
   * re-prevote the locked hash UNLESS we see a polka for a different value at
   * a round strictly greater than `lockedRound` — the "unlock" condition.
   *
   * Without this rule, the N=4 faction split observed on prod h=86,428 (round
   * counter ran to 19 over 52 seconds) can persist indefinitely: each round's
   * proposer proposes a different block, validators each prevote whatever the
   * current proposer proposed, no single block reaches the >2/3 threshold.
   *
   * Cleared on `startHeight` (next height starts unlocked).
   * Persists across `advanceRound` within the same height (the whole point).
   */
  lockedRound = null;
  lockedValueHashHex = null;
  /** Round → blockHashHex of every polka observed at this height. Used to
   * evaluate the unlock condition. Cleared on `startHeight`. */
  polkasByRound = /* @__PURE__ */ new Map();
  /**
   * Legacy single-slot late-signer set. Retained as a synchronised view
   * of the current-height slot in `lateCommittedByHeight` so reads still
   * work; writes go through the K-slot path. See #341.
   */
  lateCommittedPrecommits = /* @__PURE__ */ new Set();
  /**
   * K-slot late-precommit buffer (task #341). Keyed by committed
   * `height.toString()`, each slot holds the votes+signers that arrived
   * AFTER the engine advanced past that height. Extends the previous
   * single-slot design: late votes arriving 2+ heights after their
   * target block are still bufferable, up to `LATE_COMMIT_BUFFER_HEIGHTS`
   * committed heights back. Anything older is evicted on the next commit.
   *
   * Contract identical to the legacy single-slot buffer:
   *   - Ephemeral, in-memory only; never persisted (see field doc on
   *     `lastCommittedLateVotes` for why).
   *   - Read via `getLateCommittedSigners(forHeight)`; producer's
   *     `buildBitmapForNextBlock` merges the current-height slot into
   *     the bitmap so slow validators get rewards credit.
   *   - Cleared for the just-committed height when a fresh commit
   *     replaces its slot; other heights evicted by the K-window rule.
   */
  lateCommittedByHeight = /* @__PURE__ */ new Map();
  /** How many committed heights back the late-precommit buffer retains. */
  static LATE_COMMIT_BUFFER_HEIGHTS = 5n;
  /** Hard cap on total buffered late votes across all heights, as a
   * belt-and-braces guard against unbounded growth under a long wedge. */
  static LATE_COMMIT_BUFFER_MAX_VOTES = 500;
  /**
   * Vote-acceptance set: ACTIVE ∪ inactiveBonded. Votes from validators
   * in this set are accepted by `ingestVote` (so dormant-but-bonded
   * validators can re-admit themselves). Votes from validators NOT in
   * this set are rejected.
   *
   * Quorum is still computed against ACTIVE stake only — inactiveBonded
   * votes are recorded but contribute 0 to the bucket totals.
   */
  acceptedSet = /* @__PURE__ */ new Set();
  /**
   * Callback fired after every commit that captures the precommit set.
   * Set by node.ts to persist the cert to disk (task #316). Kept as a
   * callback rather than a constructor arg so devnet + test rigs can
   * skip persistence without wiring a stub. Invoked with the height
   * that just committed — the cert is emitted in block height+1.
   */
  persistCertHook = null;
  /**
   * Task #342 — height-ahead observation counter. Keyed by the AHEAD
   * height we saw in a peer vote; value is the set of validator
   * addresses that voted at that height. When at least
   * `HEIGHT_AHEAD_QUORUM` distinct validators vote for the same
   * height > engine.height + HEIGHT_AHEAD_GAP, we're clearly behind
   * the cluster and should trigger block-sync catchup instead of
   * spinning rounds on our stale height. Cleared on startHeight so
   * observations from just-completed heights don't leak forward.
   */
  heightAheadObservations = /* @__PURE__ */ new Map();
  /**
   * Task #342 — behind-detect callback. Node.ts wires this to
   * `kickOffCatchup(observedHeight)`. Fires exactly once per height
   * transition (guarded by a per-height "already signalled" set), so a
   * flurry of ahead-votes doesn't hammer the catchup subsystem.
   */
  onBehindDetected = null;
  heightAheadSignalled = /* @__PURE__ */ new Set();
  constructor(chainId) {
    this.chainId = chainId;
  }
  /**
   * Wire the cert-persist hook. Called once from node.ts during boot,
   * before startHeight fires for the first time. Passing null unsets.
   */
  setPersistCertHook(fn) {
    this.persistCertHook = fn;
  }
  /**
   * Task #342 — wire the behind-detect callback. Called once from
   * node.ts during boot. When engine sees ≥ HEIGHT_AHEAD_QUORUM distinct
   * validators voting at a height > our engine.height + HEIGHT_AHEAD_GAP,
   * this fires with the observed height so node.ts can trigger
   * `kickOffCatchup`. Passing null unsets.
   */
  setBehindDetectedHook(fn) {
    this.onBehindDetected = fn;
  }
  /**
   * Seed engine state from a persisted cert loaded off disk on boot.
   * Called by node.ts AFTER `startHeight(newHeight)` — the newly-started
   * height is `committedHeight + 1`, and the cert becomes what the
   * producer emits in that block's LastCommit. Idempotent + safe to
   * call zero or one times per boot.
   *
   * This is the restart-safe engine state path (task #316). Without it
   * the first proposal after a coordinated restart carries
   * `emptyLastCommit()`, which the escape hatch in verifyLastCommit
   * accepts to keep the chain alive but costs one block of rewards and
   * risks fork if timing is right (the neurenburg h=11,393 case).
   */
  restoreCommittedPrecommits(opts) {
    this.lastCommittedHeight = opts.committedHeight;
    this.lastCommittedHashHex = opts.blockHashHex;
    this.lastCommittedRound = opts.round;
    this.lastCommittedPrecommitVotes = opts.votes;
    this.lastCommittedLateVotes = [];
    console.log(
      `[engine] restored committed precommits h=${opts.committedHeight} block=${opts.blockHashHex.slice(0, 14)}\u2026 votes=${opts.votes.length}`
    );
  }
  /**
   * Sync the engine's committed hash to whatever the block store just
   * applied. Called after `applyExternalBlock` when a node receives a
   * block it didn't commit locally (i.e., someone else's engine reached
   * quorum). Without this, the engine's `lastCommittedHashHex` stays
   * pinned to whatever the local engine last considered committed —
   * which is often a losing candidate from an earlier round — while the
   * block store already advanced to the true head. Next time this node
   * proposes, `buildLastCommitForNextBlock` reads the stale hash from
   * the engine, produces a cert whose `blockHash` doesn't match the
   * chain's actual parent, and every peer rejects the proposal with
   * `lastCommit.blockHash != expected parent`. Root cause of 2026-07-08
   * h=234 and 2026-07-09 h=197 wedges.
   *
   * Clears the vote list too — we don't have the true winning-round's
   * signed precommits from block bytes alone, so the next cert will be
   * empty. Peers accept an empty cart via the transitional escape
   * hatch, so we lose reward credit for that block but keep liveness.
   */
  syncCommittedHashFromBlockStore(opts) {
    this.lastCommittedHeight = opts.committedHeight;
    this.lastCommittedHashHex = opts.blockHashHex;
    this.lastCommittedRound = 0n;
    this.lastCommittedPrecommitVotes = [];
    this.lastCommittedLateVotes = [];
  }
  /** Reset state for a new height. Called each time a block is committed. */
  startHeight(opts) {
    if (this.height > 0n) {
      this.snapshotRoundVotesForHistory(this.height, this.round);
    }
    this.height = opts.height;
    this.round = opts.round ?? 0n;
    if (process.env.ASENTUM_DEVNET === "1" && !_ConsensusEngine.devSkewApplied) {
      const skew = Number(process.env.ASENTUM_DEV_ROUND_SKEW || 0);
      if (Number.isFinite(skew) && skew > 0) {
        this.round = (opts.round ?? 0n) + BigInt(skew);
        console.log(`[engine] devnet round-skew applied: +${skew} \u2192 r=${this.round} (one-shot)`);
      }
      _ConsensusEngine.devSkewApplied = true;
    }
    this.phase = "propose";
    this.validators = [...opts.validators];
    this.validatorSet = new Set(opts.validators.map((v) => v.address.toLowerCase()));
    this.validatorStake = /* @__PURE__ */ new Map();
    for (const v of opts.validators) {
      let stake;
      try {
        stake = BigInt(v.stake);
      } catch {
        stake = 0n;
      }
      this.validatorStake.set(v.address.toLowerCase(), stake > 0n ? stake : 0n);
    }
    this.acceptedSet = new Set(this.validatorSet);
    if (opts.inactiveBonded) {
      for (const v of opts.inactiveBonded) {
        const addr = v.address.toLowerCase();
        this.acceptedSet.add(addr);
      }
    }
    this.proposerRotationSet = [
      ...opts.validators,
      ...opts.inactiveBonded ?? []
    ];
    this.prevotes = /* @__PURE__ */ new Map();
    this.precommits = /* @__PURE__ */ new Map();
    this.prevoteByValidator = /* @__PURE__ */ new Map();
    this.precommitByValidator = /* @__PURE__ */ new Map();
    this.pendingFutureVotes = /* @__PURE__ */ new Map();
    this.staleVoteBacklog = /* @__PURE__ */ new Map();
    this.staleVoteLogSeen = /* @__PURE__ */ new Set();
    this.committedBlockHash = null;
    this.lockedRound = null;
    this.lockedValueHashHex = null;
    this.polkasByRound = /* @__PURE__ */ new Map();
    this.quorumListeners.clear();
    this.lastCommittedLateVotes = [];
    this.lateCommittedPrecommits = /* @__PURE__ */ new Set();
    this.heightAheadObservations = /* @__PURE__ */ new Map();
    this.heightAheadSignalled = /* @__PURE__ */ new Set();
    console.log(`[engine] startHeight h=${this.height} r=${this.round} n=${this.validators.length} thresholdStake=${this.stakeThreshold()}`);
  }
  /** Advance to the next round at the same height (e.g. on propose timeout). */
  advanceRound() {
    this.snapshotRoundVotesForHistory(this.height, this.round);
    this.round += 1n;
    this.phase = "propose";
    this.prevotes = /* @__PURE__ */ new Map();
    this.precommits = /* @__PURE__ */ new Map();
    this.prevoteByValidator = /* @__PURE__ */ new Map();
    this.precommitByValidator = /* @__PURE__ */ new Map();
    this.committedBlockHash = null;
    this.quorumListeners.clear();
    this.staleVoteLogSeen = /* @__PURE__ */ new Set();
    console.log(`[engine] advanceRound h=${this.height} r=${this.round}`);
    const buffered = this.pendingFutureVotes.get(this.round.toString());
    if (buffered && buffered.length > 0) {
      this.pendingFutureVotes.delete(this.round.toString());
      console.log(`[engine] draining ${buffered.length} buffered vote(s) for newly-active round ${this.round}`);
      for (const vote of buffered) {
        this.ingestVote(vote);
      }
    }
    for (const key6 of this.pendingFutureVotes.keys()) {
      if (BigInt(key6) < this.round) this.pendingFutureVotes.delete(key6);
    }
    const minTolerated = this.round > _ConsensusEngine.STALE_TOLERANCE_K ? this.round - _ConsensusEngine.STALE_TOLERANCE_K : 0n;
    for (const key6 of this.staleVoteBacklog.keys()) {
      if (BigInt(key6) < minTolerated) this.staleVoteBacklog.delete(key6);
    }
    if (this.onRoundAdvancedHook) {
      try {
        this.onRoundAdvancedHook();
      } catch (err) {
        console.error(`[engine] onRoundAdvancedHook threw:`, err);
      }
    }
  }
  onRoundAdvancedHook = null;
  /** Register a hook fired after every round advance (both external and
   *  engine-internal POLKA jumps). Node layer uses it to re-arm the
   *  proposal-timeout timer. Multiple callers not supported — replaces. */
  setRoundAdvancedHook(fn) {
    this.onRoundAdvancedHook = fn;
  }
  setPhase(phase) {
    this.phase = phase;
  }
  getHeight() {
    return this.height;
  }
  /**
   * Wall-clock ms at last commit (`Date.now()` snapshot). Zero if we've
   * never committed. Producer uses this to implement the #341 grace
   * window; consensus itself never reads it.
   */
  getLastCommittedAtWallMs() {
    return this.lastCommittedAtWallMs;
  }
  getRound() {
    return this.round;
  }
  /**
   * Highest height observed in any incoming vote at this engine. Used
   * by behind-detect — when peers are voting much higher than our local
   * head, the proposals queued in the ANT receive buffer are stale and
   * should be bypassed in favour of block-sync.
   */
  getMaxSeenVoteHeight() {
    return this.maxSeenVoteHeight;
  }
  getPhase() {
    return this.phase;
  }
  getValidators() {
    return this.validators;
  }
  /**
   * Who should be proposing right now?
   *
   * Rotates over the BONDED committee (active ∪ inactiveBonded), NOT Phase
   * B's locally-filtered active set. Every node sees the same bonded set
   * (it's on-chain), so every node agrees on the scheduled proposer for
   * any (h, r). If we used `this.validators` (Phase B active) here, nodes
   * with diverged `lastVotedHeight` maps would pick different proposers
   * and reject each other's proposals — wedging the chain (h=1060,
   * 2026-06-06 prod wedge).
   */
  currentProposer() {
    return proposerForRound(this.proposerRotationSet, this.height, this.round);
  }
  /**
   * Locked-prevote state. Returns the (round, hashHex) we're locked on, or
   * null if no polka has been observed yet this height.
   */
  getLockedValue() {
    if (this.lockedRound === null || this.lockedValueHashHex === null) return null;
    return { round: this.lockedRound, hashHex: this.lockedValueHashHex };
  }
  /**
   * Unlock check: have we observed a polka at some round > sinceRound for a
   * value DIFFERENT from currentLockedHashHex? If yes, we're allowed to vote
   * for the new round's proposal (or any other value with polka support)
   * instead of re-prevoting the lock.
   */
  hasUnlockingPolka(sinceRound, currentLockedHashHex) {
    for (const [roundKey, hashHex] of this.polkasByRound.entries()) {
      const r = BigInt(roundKey);
      if (r > sinceRound && hashHex !== currentLockedHashHex) return true;
    }
    return false;
  }
  /**
   * Return ALL polkas observed at the current height, as a sorted list
   * from lowest to highest round. Used by the producer-side PoL path
   * (#284): before building a fresh block, the scheduled proposer checks
   * if ANY prior round had a polka. If yes, the proposer re-broadcasts
   * THAT block (with `polRound` set to its original polka round) instead
   * of a fresh build. Locked validators converge on the re-broadcast,
   * commit forms, chain progresses.
   *
   * Returns an empty array if no polkas observed yet this height.
   */
  getPolkasForCurrentHeight() {
    const out = [];
    for (const [roundKey, hashHex] of this.polkasByRound.entries()) {
      out.push({ round: BigInt(roundKey), hashHex });
    }
    out.sort((a, b) => a.round < b.round ? -1 : a.round > b.round ? 1 : 0);
    return out;
  }
  /**
   * Stake-weighted 2f+1 quorum threshold for the current validator set.
   * `floor(2 * totalStake / 3) + 1`. Returns 0n when no validators are bonded.
   */
  stakeThreshold() {
    return quorumThresholdStake(this.validators);
  }
  /**
   * Ingest a signed vote. Verifies signature, validator-set membership,
   * (height, round, chainId) bounds, then either buckets the vote by
   * blockHash OR — if the vote is a second conflicting vote from a
   * validator who already voted at this (height, round, kind) — records
   * a slashable double-sign and refuses to bucket the conflicting vote.
   */
  ingestVote(vote) {
    const dropList = process.env.ASENTUM_DEV_DROP_VOTES_FROM;
    if (dropList) {
      const voter = bytesToHexLower2(vote.body.validator);
      for (const raw of dropList.split(",")) {
        const target = raw.trim().toLowerCase();
        if (target && voter === target) {
          return { ok: false, reason: "dev-drop" };
        }
      }
    }
    const verify3 = verifySignedVote(vote);
    if (!verify3.ok) {
      console.log(`[engine] vote REJECTED reason=sig: ${verify3.reason}`);
      return { ok: false, reason: verify3.reason };
    }
    const MAX_PLAUSIBLE_VOTE_GAP = 1000n;
    if (vote.body.chainId === this.chainId && vote.body.height > this.maxSeenVoteHeight && vote.body.height <= this.height + MAX_PLAUSIBLE_VOTE_GAP) {
      this.maxSeenVoteHeight = vote.body.height;
    }
    if (vote.body.chainId !== this.chainId) {
      console.log(`[engine] vote REJECTED reason=chainId vote=${vote.body.chainId} engine=${this.chainId}`);
      return { ok: false, reason: `chainId mismatch: ${vote.body.chainId} vs ${this.chainId}` };
    }
    if (vote.body.height !== this.height) {
      if (vote.body.kind === VOTE_KIND_PRECOMMIT2) {
        const heightKey = vote.body.height.toString();
        const bucket2 = this.lateCommittedByHeight.get(heightKey);
        if (bucket2 !== void 0 && bytesToHexLower2(vote.body.blockHash) === bucket2.hashHex) {
          const lateValidatorAddr = bytesToHexLower2(vote.body.validator);
          const alreadyBuffered = bucket2.signers.has(lateValidatorAddr);
          if (!alreadyBuffered) {
            const totalBuffered = this.countBufferedLateVotes();
            if (totalBuffered >= _ConsensusEngine.LATE_COMMIT_BUFFER_MAX_VOTES) {
              return {
                ok: true,
                kind: "precommit",
                duplicate: true,
                doubleSign: false,
                prevoteCount: 0,
                precommitCount: 0,
                quorumReached: false
              };
            }
            bucket2.signers.add(lateValidatorAddr);
            bucket2.votes.push(vote);
            if (this.lastCommittedHeight !== null && vote.body.height === this.lastCommittedHeight) {
              this.lateCommittedPrecommits.add(lateValidatorAddr);
              this.lastCommittedLateVotes.push(vote);
            }
            console.log(
              `[engine] late precommit BUFFERED from=${lateValidatorAddr.slice(0, 14)}\u2026 for committed h=${vote.body.height} (K-slot #341, engine on h=${this.height})`
            );
          }
          return {
            ok: true,
            kind: "precommit",
            duplicate: alreadyBuffered,
            doubleSign: false,
            prevoteCount: 0,
            precommitCount: 0,
            quorumReached: false
          };
        }
      }
      const valHex2 = bytesToHexLower2(vote.body.validator);
      const logKey = `H:${valHex2}:${vote.body.height}:${vote.body.kind}`;
      if (!this.staleVoteLogSeen.has(logKey)) {
        this.staleVoteLogSeen.add(logKey);
        console.log(`[engine] vote REJECTED reason=height vote=${vote.body.height} engine=${this.height} from=${valHex2.slice(0, 14)}\u2026`);
      }
      if (vote.body.height > this.height && this.acceptedSet.has(valHex2) && this.onBehindDetected !== null) {
        const gap = vote.body.height - this.height;
        const HEIGHT_AHEAD_GAP = BigInt(
          Number(process.env.ASENTUM_BEHIND_GAP ?? "3")
        );
        const MAX_PLAUSIBLE_BEHIND_GAP = 1000n;
        if (gap >= HEIGHT_AHEAD_GAP && gap <= MAX_PLAUSIBLE_BEHIND_GAP) {
          const key6 = vote.body.height.toString();
          let set = this.heightAheadObservations.get(key6);
          if (!set) {
            set = /* @__PURE__ */ new Set();
            this.heightAheadObservations.set(key6, set);
          }
          set.add(valHex2);
          const QUORUM = Number(
            process.env.ASENTUM_BEHIND_QUORUM ?? "2"
          );
          if (set.size >= QUORUM && !this.heightAheadSignalled.has(key6)) {
            this.heightAheadSignalled.add(key6);
            console.log(
              `[engine] behind-detect: h=${this.height} < observed h=${vote.body.height} from ${set.size} validators \u2014 signalling catchup`
            );
            try {
              this.onBehindDetected(vote.body.height);
            } catch (err) {
              console.warn("[engine] onBehindDetected threw:", err);
            }
          }
        }
      }
      return {
        ok: false,
        reason: `height mismatch: vote ${vote.body.height} vs engine ${this.height}`
      };
    }
    if (vote.body.round !== this.round) {
      if (vote.body.round > this.round) {
        const ahead = vote.body.round - this.round;
        const MAX_PENDING_ROUNDS_AHEAD = 100000n;
        if (ahead > MAX_PENDING_ROUNDS_AHEAD) {
          console.log(`[engine] vote REJECTED reason=round-too-far-ahead vote=${vote.body.round} engine=${this.round} (max ahead ${MAX_PENDING_ROUNDS_AHEAD}) from=${bytesToHexLower2(vote.body.validator).slice(0, 14)}\u2026`);
          return {
            ok: false,
            reason: `round too far ahead: vote ${vote.body.round} vs engine ${this.round}`
          };
        }
        const roundKey = vote.body.round.toString();
        let bucket2 = this.pendingFutureVotes.get(roundKey);
        if (!bucket2) {
          bucket2 = [];
          this.pendingFutureVotes.set(roundKey, bucket2);
        }
        const dedupKey = `${bytesToHexLower2(vote.body.validator)}:${vote.body.kind}:${bytesToHexLower2(vote.body.blockHash)}`;
        const alreadyBuffered = bucket2.some(
          (v) => `${bytesToHexLower2(v.body.validator)}:${v.body.kind}:${bytesToHexLower2(v.body.blockHash)}` === dedupKey
        );
        if (!alreadyBuffered) {
          bucket2.push(vote);
          this.maybeTriggerPolka(vote.body.round);
        }
        return {
          ok: true,
          kind: vote.body.kind === VOTE_KIND_PREVOTE2 ? "prevote" : "precommit",
          duplicate: false,
          doubleSign: false,
          prevoteCount: 0,
          precommitCount: 0,
          quorumReached: false
        };
      }
      const lag = this.round - vote.body.round;
      const valHex = bytesToHexLower2(vote.body.validator);
      if (lag <= _ConsensusEngine.STALE_TOLERANCE_K) {
        const roundKey = vote.body.round.toString();
        let bucket2 = this.staleVoteBacklog.get(roundKey);
        if (!bucket2) {
          bucket2 = /* @__PURE__ */ new Map();
          this.staleVoteBacklog.set(roundKey, bucket2);
        }
        const dedupKey = `${valHex}:${vote.body.kind}:${bytesToHexLower2(vote.body.blockHash)}`;
        if (!bucket2.has(dedupKey)) {
          bucket2.set(dedupKey, vote);
          if (vote.body.kind === VOTE_KIND_PRECOMMIT2 && this.committedBlockHash === null) {
            const ZERO_HASH_HEX2 = "0x" + "00".repeat(32);
            const blockHashHex2 = bytesToHexLower2(vote.body.blockHash);
            if (blockHashHex2 !== ZERO_HASH_HEX2) {
              let votersForHash = 0;
              for (const v of bucket2.values()) {
                if (v.body.kind !== VOTE_KIND_PRECOMMIT2) continue;
                if (bytesToHexLower2(v.body.blockHash) === blockHashHex2) votersForHash++;
              }
              const quorumCount = Math.floor(this.validators.length * 2 / 3) + 1;
              if (votersForHash >= quorumCount) {
                const candidate = [];
                for (const v of bucket2.values()) {
                  if (v.body.kind === VOTE_KIND_PRECOMMIT2) candidate.push(v);
                }
                const result = this.commitFromPrecommitPolka(this.height, candidate);
                if (result.committed) {
                  console.log(
                    `[engine] stale-tolerance: precommit polka recognised at r=${vote.body.round} (engine r=${this.round}) \u2192 committed`
                  );
                }
              }
            }
          }
          if (vote.body.kind === VOTE_KIND_PREVOTE2 && this.committedBlockHash === null) {
            const ZERO_HASH_HEX2 = "0x" + "00".repeat(32);
            const blockHashHex2 = bytesToHexLower2(vote.body.blockHash);
            if (blockHashHex2 !== ZERO_HASH_HEX2) {
              const roundNum = vote.body.round;
              const canTighten = this.lockedRound === null || this.lockedRound < roundNum;
              if (canTighten) {
                const votersSet = /* @__PURE__ */ new Set();
                let stakeForHash = 0n;
                const consider = (candidate) => {
                  if (candidate.body.kind !== VOTE_KIND_PREVOTE2) return;
                  if (bytesToHexLower2(candidate.body.blockHash) !== blockHashHex2) return;
                  const va = bytesToHexLower2(candidate.body.validator);
                  if (votersSet.has(va)) return;
                  votersSet.add(va);
                  stakeForHash += this.stakeOf(va);
                };
                for (const v of bucket2.values()) consider(v);
                const histKey = `${this.height}:${roundNum}:prevote`;
                const hist = this.voteHistory.get(histKey);
                if (hist) for (const v of hist) consider(v);
                const threshold2 = this.stakeThreshold();
                if (stakeForHash >= threshold2) {
                  const polkaRoundKey = roundNum.toString();
                  this.polkasByRound.set(polkaRoundKey, blockHashHex2);
                  this.lockedRound = roundNum;
                  this.lockedValueHashHex = blockHashHex2;
                  console.log(
                    `[engine] LOCK-RETRO h=${this.height} r=${roundNum} on=${blockHashHex2.slice(0, 14)}\u2026 (via stale backlog+history, engine r=${this.round})`
                  );
                }
              }
            }
          }
        }
        return {
          ok: true,
          kind: vote.body.kind === VOTE_KIND_PREVOTE2 ? "prevote" : "precommit",
          duplicate: false,
          doubleSign: false,
          prevoteCount: 0,
          precommitCount: 0,
          quorumReached: false
        };
      }
      const logKey = `${valHex}:${vote.body.round}:${bytesToHexLower2(vote.body.blockHash)}`;
      if (!this.staleVoteLogSeen.has(logKey)) {
        this.staleVoteLogSeen.add(logKey);
        console.log(`[engine] vote REJECTED reason=round-stale vote=${vote.body.round} engine=${this.round} from=${valHex.slice(0, 14)}\u2026 (lag=${lag} > K=${_ConsensusEngine.STALE_TOLERANCE_K})`);
      }
      return {
        ok: false,
        reason: `stale round: vote ${vote.body.round} vs engine ${this.round}`
      };
    }
    const validatorAddr = bytesToHexLower2(vote.body.validator);
    if (!this.acceptedSet.has(validatorAddr)) {
      console.log(`[engine] vote REJECTED reason=not-in-set from=${validatorAddr.slice(0, 14)}\u2026`);
      return { ok: false, reason: `validator ${validatorAddr} not in bonded set` };
    }
    const isReadmissionCandidate = !this.validatorSet.has(validatorAddr);
    const kindLabel = vote.body.kind === VOTE_KIND_PREVOTE2 ? "prevote" : "precommit";
    const bucket = kindLabel === "prevote" ? this.prevotes : this.precommits;
    const byValidator = kindLabel === "prevote" ? this.prevoteByValidator : this.precommitByValidator;
    const blockHashHex = bytesToHexLower2(vote.body.blockHash);
    const previous = byValidator.get(validatorAddr);
    if (previous) {
      if (previous.blockHashHex === blockHashHex) {
        return {
          ok: true,
          kind: kindLabel,
          duplicate: true,
          doubleSign: false,
          prevoteCount: this.countForBucket(this.prevotes, blockHashHex),
          precommitCount: this.countForBucket(this.precommits, blockHashHex),
          quorumReached: false
        };
      }
      const evidence = {
        offender: vote.body.validator,
        kind: kindLabel,
        height: this.height,
        round: this.round,
        blockHashA: previous.vote.body.blockHash,
        blockHashB: vote.body.blockHash,
        voteA: previous.vote,
        voteB: vote,
        detectedAt: Date.now()
      };
      this.recentEvidence.push(evidence);
      for (const l of this.doubleSignListeners) {
        try {
          l(evidence);
        } catch (err) {
          console.error("[consensus] double-sign listener failed:", err);
        }
      }
      return {
        ok: true,
        kind: kindLabel,
        duplicate: false,
        doubleSign: true,
        evidence,
        prevoteCount: this.countForBucket(this.prevotes, blockHashHex),
        precommitCount: this.countForBucket(this.precommits, blockHashHex),
        quorumReached: false
      };
    }
    byValidator.set(validatorAddr, { blockHashHex, vote });
    let slot = bucket.get(blockHashHex);
    if (!slot) {
      slot = /* @__PURE__ */ new Set();
      bucket.set(blockHashHex, slot);
    }
    const beforeStake = this.bucketStake(slot);
    slot.add(validatorAddr);
    const afterStake = this.bucketStake(slot);
    const threshold = this.stakeThreshold();
    const quorumReached = beforeStake < threshold && afterStake >= threshold;
    const count = slot.size;
    console.log(
      `[engine] vote ACCEPTED kind=${kindLabel} h=${this.height} r=${this.round} from=${validatorAddr.slice(0, 14)}\u2026 block=${blockHashHex.slice(0, 14)}\u2026 count=${count} stake=${afterStake}/${threshold}${quorumReached ? " QUORUM" : ""}`
    );
    if (quorumReached && kindLabel === "prevote" && this.phase === "prevote") {
      this.phase = "precommit";
    }
    if (quorumReached && kindLabel === "prevote") {
      const roundKey = this.round.toString();
      this.polkasByRound.set(roundKey, blockHashHex);
      this.lockedRound = this.round;
      this.lockedValueHashHex = blockHashHex;
      console.log(
        `[engine] LOCK h=${this.height} r=${this.round} on=${blockHashHex.slice(0, 14)}\u2026`
      );
    }
    if (quorumReached && kindLabel === "precommit" && this.phase === "precommit") {
      this.phase = "commit";
      this.committedBlockHash = blockHashHex;
      this.lastCommittedHeight = this.height;
      this.lastCommittedHashHex = blockHashHex;
      this.lastCommittedRound = this.round;
      this.lastCommittedAtWallMs = Date.now();
      this.lateCommittedPrecommits = /* @__PURE__ */ new Set();
      this.lastCommittedLateVotes = [];
      this.openLateCommitSlot(this.height, blockHashHex);
      this.lastCommittedPrecommitVotes = [];
      for (const entry of this.precommitByValidator.values()) {
        if (entry.blockHashHex === blockHashHex) {
          this.lastCommittedPrecommitVotes.push(entry.vote);
        }
      }
      if (this.persistCertHook) {
        try {
          this.persistCertHook(
            this.height,
            blockHashHex,
            this.round,
            this.lastCommittedPrecommitVotes
          );
        } catch (err) {
          console.error("[engine] persistCertHook (inline) threw:", err);
        }
      }
    }
    for (const listener of this.quorumListeners) {
      try {
        listener(kindLabel, blockHashHex, afterStake);
      } catch (err) {
        console.error("[consensus] quorum listener failed:", err);
      }
    }
    return {
      ok: true,
      kind: kindLabel,
      duplicate: false,
      doubleSign: false,
      prevoteCount: this.countForBucket(this.prevotes, blockHashHex),
      precommitCount: this.countForBucket(this.precommits, blockHashHex),
      quorumReached
    };
  }
  /**
   * Subscribe to double-sign detection events. The listener fires
   * synchronously inside `ingestVote` whenever a conflicting second
   * vote is observed from a validator. The slashing pipeline uses
   * this to auto-submit evidence to the slashing system contract.
   */
  onDoubleSign(listener) {
    this.doubleSignListeners.add(listener);
    return () => this.doubleSignListeners.delete(listener);
  }
  /**
   * Subscribe to polka-commit events — fires when commitFromPrecommitPolka
   * commits a block. The node uses this to drive the same persist + advance
   * work that the inline own-block / external-block paths already do.
   */
  onPolkaCommit(listener) {
    this.polkaCommitListeners.add(listener);
    return () => this.polkaCommitListeners.delete(listener);
  }
  /** Snapshot of recently-detected double-sign evidence at this engine. */
  getRecentEvidence() {
    return this.recentEvidence;
  }
  /** Drain and clear the recent evidence buffer. Returns whatever was in it. */
  drainEvidence() {
    const out = this.recentEvidence;
    this.recentEvidence = [];
    return out;
  }
  /**
   * Wait until the engine sees 2f+1 votes of the given kind for the
   * given block hash at the current (height, round). Returns the
   * observed count when quorum is reached, rejects with a Timeout
   * error if the wait exceeds `timeoutMs`.
   *
   * When threshold=1, the producer's own ingested vote satisfies this
   * immediately and the function returns on the next microtask.
   *
   * @param gracePeriodMs — once stake threshold is crossed, wait this
   *   long for additional votes to arrive before resolving. Resolves
   *   with the FINAL (post-grace) bucket stake so callers see the most
   *   complete signer set possible. Defaults to 0 (resolve immediately
   *   on quorum). Used on the precommit phase to widen the lastCommitBitmap
   *   signer set — without grace, the 4th-fastest validator's precommit
   *   often arrives microseconds after quorum, gets bucketed, but is
   *   never reflected in the bitmap captured by `captureCurrentBlockMeta`.
   *   The grace period collapses that race so all four (typically) make it
   *   in. Grace doesn't affect safety — it only delays resolve, not commit.
   */
  waitForQuorum(kind, blockHash, timeoutMs, gracePeriodMs = 0) {
    const blockHashHex = bytesToHexLower2(blockHash);
    const bucket = kind === "prevote" ? this.prevotes : this.precommits;
    const threshold = this.stakeThreshold();
    const currentStake = () => {
      const s = bucket.get(blockHashHex);
      return s ? this.bucketStake(s) : 0n;
    };
    const initialStake = currentStake();
    if (initialStake >= threshold) {
      if (gracePeriodMs <= 0) return Promise.resolve(initialStake);
      return new Promise((resolve) => {
        setTimeout(() => resolve(currentStake()), gracePeriodMs);
      });
    }
    return new Promise((resolve, reject) => {
      let settled = false;
      let graceTimer = null;
      const finalize = () => {
        if (settled) return;
        settled = true;
        this.quorumListeners.delete(listener);
        clearTimeout(hardTimer);
        if (graceTimer !== null) clearTimeout(graceTimer);
        resolve(currentStake());
      };
      const listener = (gotKind, gotHashHex, stake) => {
        if (settled) return;
        if (graceTimer !== null) return;
        if (gotKind !== kind) return;
        if (gotHashHex !== blockHashHex) return;
        if (stake >= threshold) {
          if (gracePeriodMs <= 0) {
            finalize();
          } else {
            graceTimer = setTimeout(finalize, gracePeriodMs);
          }
        }
      };
      const hardTimer = setTimeout(() => {
        if (settled) return;
        settled = true;
        this.quorumListeners.delete(listener);
        if (graceTimer !== null) clearTimeout(graceTimer);
        const slotNow = bucket.get(blockHashHex);
        const observedStake = slotNow ? this.bucketStake(slotNow) : 0n;
        const observedCount = slotNow?.size ?? 0;
        reject(
          new Error(
            `consensus: timed out waiting for ${kind} quorum on ${blockHashHex} after ${timeoutMs}ms (have ${observedCount} validators with stake=${observedStake}/${threshold})`
          )
        );
      }, timeoutMs);
      this.quorumListeners.add(listener);
    });
  }
  /**
   * Has the engine seen 2f+1 pre-commits for the given block hash at
   * the current (height, round)? If so, the node may finalize the block.
   */
  commitResultFor(blockHash) {
    const blockHashHex = bytesToHexLower2(blockHash);
    const slot = this.precommits.get(blockHashHex);
    const count = slot?.size ?? 0;
    const stake = slot ? this.bucketStake(slot) : 0n;
    const threshold = this.stakeThreshold();
    return { committed: stake >= threshold, count, stake, threshold };
  }
  /**
   * Convenience: has the engine reached a committed state on any block
   * hash at the current round? Returns the hex hash if yes.
   */
  committedHash() {
    return this.committedBlockHash;
  }
  /**
   * Snapshot of the signed votes the engine has accepted for the current
   * (height, round) and the requested kind. Used by the pull-mode vote
   * relay endpoint (#264) so NAT'd operators can fetch peer votes they
   * couldn't receive via inbound HTTP and drive their local engine to
   * quorum.
   *
   * Returns `null` if the caller's (h, r) doesn't match the engine's
   * current (h, r) — the in-flight per-validator maps clear on
   * `startHeight`/`advanceRound`, so any other (h, r) is by definition
   * uncached and the right answer is "no votes available". The caller's
   * RPC layer turns `null` into `{ available: false }`.
   *
   * The returned array is a snapshot (Array.from), not a live view, so
   * the caller can serialize without racing the engine.
   */
  getCurrentRoundVotes(kind, forHeight, forRound) {
    if (forHeight === this.height && forRound === this.round) {
      const source = kind === "prevote" ? this.prevoteByValidator : this.precommitByValidator;
      const out = [];
      for (const rec of source.values()) out.push(rec.vote);
      return out;
    }
    const hist = this.voteHistory.get(`${forHeight}:${forRound}:${kind}`);
    return hist ? [...hist] : null;
  }
  /**
   * Capture the current per-validator vote sets for (h, r) into the history
   * ring. Called from advanceRound (before clearing the maps) and from
   * startHeight (before reassigning this.height for the new height) so the
   * ring always holds the FINAL state of every visited round. Ring is sized
   * by `voteHistoryDepth` heights; eviction is FIFO by height.
   */
  snapshotRoundVotesForHistory(h, r) {
    const snapshot = (kind) => {
      const src = kind === "prevote" ? this.prevoteByValidator : this.precommitByValidator;
      if (src.size === 0) return;
      const arr = [];
      for (const rec of src.values()) arr.push(rec.vote);
      this.voteHistory.set(`${h}:${r}:${kind}`, arr);
    };
    snapshot("prevote");
    snapshot("precommit");
    if (!this.voteHistoryHeightOrder.includes(h)) {
      this.voteHistoryHeightOrder.push(h);
      while (this.voteHistoryHeightOrder.length > _ConsensusEngine.voteHistoryDepth) {
        const evictH = this.voteHistoryHeightOrder.shift();
        const prefix = `${evictH}:`;
        for (const k of [...this.voteHistory.keys()]) {
          if (k.startsWith(prefix)) this.voteHistory.delete(k);
        }
      }
    }
  }
  /**
   * #267 Phase 3 — out-of-band commit via verified precommit polka.
   *
   * The pull-mode operator's local engine often advances past r=0 (its
   * own propose timeouts fire while the relay is already committing in
   * r=0). When the operator then polls the relay's `/consensus/votes`
   * for (height, r=0) and Phase 1 happily serves the historical
   * precommits, the engine's normal `ingestVote` rejects them as
   * stale-round (engine.ts L483: any vote with `body.round < this.round`
   * is dropped). The polka exists, the operator can fetch it, but the
   * ingest path won't let them act on it.
   *
   * This method is a separate, narrower ingest entrypoint: given any set
   * of precommit votes at `this.height`, verify each one independently,
   * bucket by blockHash, and if any bucket meets the stake threshold,
   * commit that block — regardless of the round the operator's engine
   * happens to be on. Same safety: every signature is verified, every
   * voter must be in the accepted (bonded) set, and the stake bucketing
   * uses the same threshold as the normal commit path.
   *
   * Does NOT advance the engine to the next height. Caller is responsible
   * for persisting the block and calling startHeight after.
   *
   * Idempotent: if the engine is already committed at `this.height`,
   * returns committed:true without re-doing the work.
   *
   * Returns the verified signers so the caller can broadcast its own
   * matching precommit to relays (so its bit lands in the next block's
   * lastCommitBitmap when phase 4's late-acceptance window catches it).
   */
  /**
   * Shared precommit bucketing used by both commitFromPrecommitPolka
   * (Phase 3, mutating) and detectPrecommitPolka (Phase 4', non-mutating).
   *
   * Buckets verified precommit votes by blockHash at `forHeight`. Each
   * vote is validated independently — chainId, kind, height, hash !=
   * zero, voter in committee, voter has stake, signature verifies —
   * then deduped per (bucket, validator) and accumulated.
   *
   * Tracks the round of each bucket (first round seen for that hash).
   * In honest execution every vote in a bucket shares one round; a
   * Byzantine mix is benign because the relay's late-precommit buffer
   * does not gate on round.
   *
   * The returned `qualifying` list contains every bucket whose stake
   * met the threshold. Callers handle the multi-polka guard themselves
   * since their failure semantics differ (refuse-to-commit vs. refuse-
   * to-sign).
   */
  bucketPrecommitsForHeight(forHeight, votes) {
    const buckets = /* @__PURE__ */ new Map();
    const ZERO_HASH_HEX2 = "0x" + "00".repeat(32);
    for (const vote of votes) {
      if (vote.body.chainId !== this.chainId) continue;
      if (vote.body.kind !== VOTE_KIND_PRECOMMIT2) continue;
      if (vote.body.height !== forHeight) continue;
      const blockHashHex = bytesToHexLower2(vote.body.blockHash);
      if (blockHashHex === ZERO_HASH_HEX2) continue;
      const validatorHex = bytesToHexLower2(vote.body.validator);
      if (!this.acceptedSet.has(validatorHex)) continue;
      const stake = this.validatorStake.get(validatorHex) ?? 0n;
      if (stake === 0n) continue;
      const verify3 = verifySignedVote(vote);
      if (!verify3.ok) continue;
      let bucket = buckets.get(blockHashHex);
      if (!bucket) {
        bucket = { polkaRound: null, stake: 0n, voters: /* @__PURE__ */ new Set(), votes: [] };
        buckets.set(blockHashHex, bucket);
      }
      if (bucket.voters.has(validatorHex)) continue;
      bucket.voters.add(validatorHex);
      bucket.stake += stake;
      bucket.votes.push(vote);
      if (bucket.polkaRound === null) bucket.polkaRound = vote.body.round;
    }
    const threshold = this.stakeThreshold();
    const qualifying = [];
    for (const [hash, bucket] of buckets) {
      if (bucket.stake >= threshold && bucket.polkaRound !== null) {
        qualifying.push({
          blockHashHex: hash,
          polkaRound: bucket.polkaRound,
          stake: bucket.stake,
          voters: bucket.voters,
          votes: bucket.votes
        });
      }
    }
    return { qualifying, threshold };
  }
  /**
   * #267 Phase 4' — non-mutating polka detector. Same verification
   * rules as commitFromPrecommitPolka but does NOT touch engine state.
   * Used by the operator's VotePoller to decide whether to sign+broadcast
   * its own late precommit (the actual commit happens via block-sync
   * later, this is just about getting the operator's bit into the
   * relay's next block bitmap).
   *
   * Returns null on no polka, no qualifying bucket, multi-polka (refuse
   * — see commit-from-polka safety note for the fork rationale), or
   * if the engine has already committed at this height.
   */
  detectPrecommitPolka(forHeight, votes) {
    if (forHeight !== this.height) return null;
    if (this.committedBlockHash !== null) return null;
    const { qualifying } = this.bucketPrecommitsForHeight(forHeight, votes);
    if (qualifying.length !== 1) return null;
    const q = qualifying[0];
    return {
      blockHashHex: q.blockHashHex,
      polkaRound: q.polkaRound,
      signerCount: q.voters.size
    };
  }
  commitFromPrecommitPolka(forHeight, votes) {
    if (forHeight !== this.height) {
      return { committed: false, blockHashHex: null, verifiedSigners: [] };
    }
    if (this.committedBlockHash !== null) {
      return {
        committed: true,
        blockHashHex: this.committedBlockHash,
        verifiedSigners: []
      };
    }
    const { qualifying, threshold } = this.bucketPrecommitsForHeight(
      forHeight,
      votes
    );
    if (qualifying.length > 1) {
      console.log(
        `[engine] commit-from-polka REFUSED h=${forHeight} reason=multi-polka count=${qualifying.length}`
      );
      return { committed: false, blockHashHex: null, verifiedSigners: [] };
    }
    if (qualifying.length === 1) {
      const q = qualifying[0];
      const blockHashHex = q.blockHashHex;
      this.committedBlockHash = blockHashHex;
      this.phase = "commit";
      this.lastCommittedHeight = this.height;
      this.lastCommittedHashHex = blockHashHex;
      this.lastCommittedRound = q.polkaRound;
      this.lateCommittedPrecommits = /* @__PURE__ */ new Set();
      this.lastCommittedLateVotes = [];
      this.openLateCommitSlot(this.height, blockHashHex);
      this.lastCommittedPrecommitVotes = q.votes.slice();
      console.log(
        `[engine] commit-from-polka h=${forHeight} block=${blockHashHex.slice(0, 14)}\u2026 signers=${q.voters.size} stake=${q.stake}/${threshold}`
      );
      if (this.persistCertHook) {
        try {
          this.persistCertHook(
            forHeight,
            blockHashHex,
            q.polkaRound,
            this.lastCommittedPrecommitVotes
          );
        } catch (err) {
          console.error("[engine] persistCertHook (polka) threw:", err);
        }
      }
      const commitHeight = forHeight;
      for (const l of this.polkaCommitListeners) {
        try {
          l(commitHeight, blockHashHex);
        } catch (err) {
          console.error("[engine] polka-commit listener failed:", err);
        }
      }
      return { committed: true, blockHashHex, verifiedSigners: q.votes };
    }
    return { committed: false, blockHashHex: null, verifiedSigners: [] };
  }
  /** Snapshot of engine state, JSON-safe for RPC exposure. */
  snapshot() {
    const proposer = this.currentProposer();
    const bucketsOut = (buckets) => {
      const out = [];
      for (const [hash, set] of buckets) {
        out.push({ blockHash: hash, count: set.size, stake: this.bucketStake(set).toString() });
      }
      out.sort((a, b) => b.count - a.count);
      return out;
    };
    return {
      height: this.height.toString(),
      round: this.round.toString(),
      phase: this.phase,
      proposer: proposer ? proposer.address : null,
      validatorCount: this.validators.length,
      // Stake-weighted threshold serialized as a decimal string for
      // JSON-safety (BigInt isn't JSON-serializable). Consumers parse with BigInt().
      quorumThresholdStake: this.stakeThreshold().toString(),
      prevoteCounts: bucketsOut(this.prevotes),
      precommitCounts: bucketsOut(this.precommits),
      committedBlockHash: this.committedBlockHash
    };
  }
  countForBucket(bucket, blockHashHex) {
    return bucket.get(blockHashHex)?.size ?? 0;
  }
  /**
   * Return the addresses of validators whose precommit landed in the
   * canonical block (matching `blockHash`) at the current (height, round).
   * Used by the rewards distributor to identify signers eligible for the
   * 80% signer-share slice. Validators offline for the precommit phase —
   * or who voted for a different hash — are NOT in this list, so they
   * earn nothing for this block. That's the automatic liveness incentive.
   *
   * The returned addresses are lowercase 0x-prefixed hex strings.
   */
  getCommittedSigners(blockHash) {
    const blockHashHex = bytesToHexLower2(blockHash);
    const out = [];
    for (const [validatorAddr, record] of this.precommitByValidator) {
      if (record.blockHashHex === blockHashHex) out.push(validatorAddr);
    }
    return out;
  }
  /**
   * Signers from the LATE-PRECOMMIT BUFFER for a specific committed
   * height (precommits that arrived AFTER the engine advanced past
   * that height). The producer of the next block unions the current
   * committed height's late signers into the bitmap so slow validators
   * still get credited. Returns lowercase 0x-prefixed addresses.
   *
   * `forHeight` defaults to the just-committed height for backward
   * compatibility. Task #341 introduced K-slot storage; callers that
   * know which height's bitmap they're building should pass the
   * explicit height to guard against buffer-rotation races.
   *
   * The buffer for a given height is retained until the K-window rolls
   * past it on `startHeight` (see `LATE_COMMIT_BUFFER_HEIGHTS`).
   */
  getLateCommittedSigners(forHeight) {
    if (forHeight === void 0) {
      return Array.from(this.lateCommittedPrecommits);
    }
    const bucket = this.lateCommittedByHeight.get(forHeight.toString());
    return bucket ? Array.from(bucket.signers) : [];
  }
  /**
   * Called by the commit paths (inline + commit-from-polka) to open a
   * fresh late-precommit slot for a newly-committed height. Also
   * enforces the K-height retention window by evicting older slots.
   */
  openLateCommitSlot(height, blockHashHex) {
    this.lateCommittedByHeight.set(height.toString(), {
      hashHex: blockHashHex,
      signers: /* @__PURE__ */ new Set(),
      votes: []
    });
    const minRetained = height > _ConsensusEngine.LATE_COMMIT_BUFFER_HEIGHTS ? height - _ConsensusEngine.LATE_COMMIT_BUFFER_HEIGHTS : 0n;
    for (const key6 of this.lateCommittedByHeight.keys()) {
      if (BigInt(key6) < minRetained) this.lateCommittedByHeight.delete(key6);
    }
  }
  /** Sum votes across all late-commit slots. Used to enforce the hard
   * upper bound on buffered votes as a wedge-safety measure. */
  countBufferedLateVotes() {
    let n = 0;
    for (const bucket of this.lateCommittedByHeight.values()) {
      n += bucket.votes.length;
    }
    return n;
  }
  /**
   * The precommit signatures that quorumed the block this engine just
   * committed at `lastCommittedHeight`. The next block's producer reads
   * this to build the on-chain LastCommit cert.
   *
   * `blockHash` — the just-committed block's hash (32 bytes).
   * `round`     — the round it finalized on (part of the vote-signing domain).
   * `votes`     — one SignedVote per validator that precommitted. The
   *               caller maps each vote's `validator` bytes into a
   *               committee index and emits `{ validatorIdx, signature }`.
   *
   * Returns null if no block has been committed yet. Note this covers
   * ONLY the ≥2/3 votes captured at commit time; late precommits should
   * be fetched separately via `getLateCommittedSigners` if the caller
   * wants to widen credit before the cert closes.
   */
  getCommittedPrecommits() {
    if (!this.lastCommittedHashHex) return null;
    const hex2 = this.lastCommittedHashHex.startsWith("0x") ? this.lastCommittedHashHex.slice(2) : this.lastCommittedHashHex;
    const bytes = new Uint8Array(hex2.length / 2);
    for (let i = 0; i < bytes.length; i++) {
      bytes[i] = parseInt(hex2.slice(i * 2, i * 2 + 2), 16);
    }
    return {
      blockHash: bytes,
      round: this.lastCommittedRound,
      votes: this.lastCommittedPrecommitVotes
    };
  }
  /**
   * Stake of a single validator in the current committee, in wei. Returns
   * 0n if the address isn't in the committee. Used by the rewards
   * distributor to weight the signer-share split by stake.
   */
  stakeOf(address) {
    return this.validatorStake.get(address.toLowerCase()) ?? 0n;
  }
  /**
   * Sum the stake of every validator in a bucket. Returns 0n for an
   * empty bucket. Validators not in the current set count as zero —
   * this can happen briefly during a validator-set rotation when a
   * stale vote is bucketed against the new committee.
   */
  bucketStake(slot) {
    let sum = 0n;
    for (const addr of slot) {
      sum += this.validatorStake.get(addr) ?? 0n;
    }
    return sum;
  }
  /** True if any block at the current round has crossed the prevote
   * stake threshold. Used to gate POLKA away from a commit-ready round. */
  currentRoundHasPrevoteQuorum() {
    const threshold = this.stakeThreshold();
    if (threshold === 0n) return false;
    for (const slot of this.prevotes.values()) {
      if (this.bucketStake(slot) >= threshold) return true;
    }
    return false;
  }
  /** POLKA pacemaker rule. Jump to the highest buffered future round
   * when f+1 stake (one honest validator's worth) sits there or above.
   * Aggregated across all future-round buckets since drifted validators
   * end up scattered across many rounds and per-round counts never
   * cross threshold. Gated against abandoning a prevote-quorum round. */
  maybeTriggerPolka(touchedRound) {
    if (touchedRound <= this.round) return;
    const uniqueValidators = /* @__PURE__ */ new Set();
    let stakeAtFuture = 0n;
    let maxRound = 0n;
    for (const [roundStr, bucket] of this.pendingFutureVotes) {
      const r = BigInt(roundStr);
      if (r <= this.round) continue;
      if (r > maxRound) maxRound = r;
      for (const v of bucket) {
        const addr = bytesToHexLower2(v.body.validator);
        if (uniqueValidators.has(addr)) continue;
        uniqueValidators.add(addr);
        stakeAtFuture += this.validatorStake.get(addr) ?? 0n;
      }
    }
    if (maxRound <= this.round) return;
    const totalStake2 = totalBondedStake(this.validators);
    if (totalStake2 === 0n) return;
    const polkaThreshold = totalStake2 / 3n + 1n;
    if (stakeAtFuture < polkaThreshold) return;
    if (this.currentRoundHasPrevoteQuorum()) {
      console.log(
        `[engine] POLKA gated: future stake=${stakeAtFuture}/${polkaThreshold} across ${uniqueValidators.size} validators (max r=${maxRound}), staying at r=${this.round} (prevote quorum present)`
      );
      return;
    }
    console.log(
      `[engine] POLKA \u2192 jumping r=${this.round} \u2192 r=${maxRound} (${uniqueValidators.size} validators ahead, stake=${stakeAtFuture}/${polkaThreshold})`
    );
    while (this.round < maxRound) {
      this.advanceRound();
    }
  }
};
function bytesToHexLower2(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}

// src/consensus/vote-record.ts
import { promises as fs } from "fs";
import { join as join2 } from "path";
function bytesToHex10(b) {
  let s = "0x";
  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
  return s;
}
function hexToBytes4(hex2) {
  const clean = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  if (clean.length % 2 !== 0) throw new Error(`hexToBytes: odd-length hex string`);
  const out = new Uint8Array(clean.length / 2);
  for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
  return out;
}
function bytesEqual4(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 FILE_NAME = "votes.json";
var TMP_SUFFIX = ".tmp";
var PRUNE_RETAIN_BLOCKS = 100n;
var VoteRecord = class {
  path;
  /** key = `${height}:${round}:${kind}`, value = recorded blockHash. */
  votes = /* @__PURE__ */ new Map();
  /** Serialize concurrent flushes — overlapping renames could corrupt. */
  writeChain = Promise.resolve();
  constructor(dataDir) {
    this.path = join2(dataDir, FILE_NAME);
  }
  /** Load any prior votes from disk. Safe to call once on node init. */
  async load() {
    let raw;
    try {
      raw = await fs.readFile(this.path, "utf8");
    } catch (err) {
      if (err.code === "ENOENT") return;
      throw err;
    }
    let parsed;
    try {
      parsed = JSON.parse(raw);
    } catch {
      throw new Error(
        `vote-record: ${this.path} exists but is not valid JSON. Will not start the validator until this is resolved (manual deletion is acceptable ONLY if you are certain the validator has not signed any votes since the file was last good).`
      );
    }
    for (const entry of parsed.votes ?? []) {
      this.votes.set(this.key(BigInt(entry.height), BigInt(entry.round), entry.kind), hexToBytes4(entry.blockHash));
    }
  }
  /**
   * Check whether signing this vote is safe. Pure — does not record.
   *
   * Returns:
   *   - `{ ok: true }` — fresh vote, safe to sign and record.
   *   - `{ ok: true, duplicate: true }` — same (h, r, kind, blockHash)
   *     already recorded. Caller can re-broadcast the existing vote, or
   *     skip — either way no new signature is needed.
   *   - `{ ok: false, reason }` — would conflict with a previously-recorded
   *     vote at the same (h, r, kind). REFUSE TO SIGN.
   */
  canSign(height, round, kind, blockHash) {
    const k = this.key(height, round, kind);
    const existing = this.votes.get(k);
    if (!existing) return { ok: true };
    if (bytesEqual4(existing, blockHash)) return { ok: true, duplicate: true };
    return {
      ok: false,
      reason: `would double-sign at h=${height} r=${round} kind=${kind}: previously signed for ${bytesToHex10(existing).slice(0, 18)}\u2026, attempted ${bytesToHex10(blockHash).slice(0, 18)}\u2026`
    };
  }
  /**
   * Atomically record a vote intent. Persisted to disk BEFORE this
   * resolves, so a crash after this point cannot lose the record.
   *
   * Throws if the vote would be a double-sign (same h, r, kind but
   * different blockHash). The caller should NOT proceed to sign on throw.
   *
   * No-op if the same vote is already recorded (idempotent retry).
   */
  async record(height, round, kind, blockHash) {
    const check = this.canSign(height, round, kind, blockHash);
    if (!check.ok) throw new Error(check.reason);
    if ("duplicate" in check && check.duplicate) return;
    this.votes.set(this.key(height, round, kind), Uint8Array.from(blockHash));
    await this.flush();
  }
  /**
   * Drop entries below `belowHeight`. Caller should invoke this after a
   * block commit, passing `committedHeight - PRUNE_RETAIN_BLOCKS` so we
   * keep a buffer for safety but don't accumulate forever.
   */
  async prune(belowHeight) {
    if (belowHeight <= 0n) return;
    let changed = false;
    for (const k of this.votes.keys()) {
      const h = BigInt(k.split(":")[0]);
      if (h < belowHeight) {
        this.votes.delete(k);
        changed = true;
      }
    }
    if (changed) await this.flush();
  }
  /**
   * Helper for callers using "current head" semantics. Pass the height
   * of the just-committed block; we prune everything older than the
   * retention window.
   */
  async pruneAfterCommit(committedHeight) {
    if (committedHeight <= PRUNE_RETAIN_BLOCKS) return;
    await this.prune(committedHeight - PRUNE_RETAIN_BLOCKS);
  }
  /** Number of recorded votes — exposed for diagnostics + tests. */
  size() {
    return this.votes.size;
  }
  key(height, round, kind) {
    return `${height}:${round}:${kind}`;
  }
  flush() {
    this.writeChain = this.writeChain.then(() => this.flushNow());
    return this.writeChain;
  }
  async flushNow() {
    const entries = [];
    for (const [k, hash] of this.votes) {
      const [h, r, kind] = k.split(":");
      entries.push({
        height: h,
        round: r,
        kind,
        blockHash: bytesToHex10(hash)
      });
    }
    const file = { version: 1, votes: entries };
    const json = JSON.stringify(file, null, 2);
    const tmpPath = this.path + TMP_SUFFIX;
    await fs.writeFile(tmpPath, json, "utf8");
    await fs.rename(tmpPath, this.path);
  }
};

// src/consensus/committee-shuffle.ts
import { blake3 as blake313 } from "@asentum/crypto";
function computeShuffleSeed(epochNumber, prevEpochLastBlockHash) {
  if (prevEpochLastBlockHash.length !== 32) {
    throw new Error(`computeShuffleSeed: prevEpochLastBlockHash must be 32 bytes, got ${prevEpochLastBlockHash.length}`);
  }
  const input = new Uint8Array(48);
  writeUint128BE2(input, 0, epochNumber);
  input.set(prevEpochLastBlockHash, 16);
  return blake313(input);
}
function deterministicShuffle(items, seed) {
  if (items.length <= 1) return [...items];
  if (seed.length !== 32) {
    throw new Error(`deterministicShuffle: seed must be 32 bytes, got ${seed.length}`);
  }
  const out = [...items];
  const stream = new HashStream(seed);
  for (let i = out.length - 1; i > 0; i--) {
    const range = BigInt(i + 1);
    const r = stream.nextUint128() % range;
    const j = Number(r);
    if (j !== i) {
      const tmp = out[i];
      out[i] = out[j];
      out[j] = tmp;
    }
  }
  return out;
}
var HashStream = class {
  seed;
  counter = 0;
  cache = null;
  cacheOffset = 0;
  constructor(seed) {
    this.seed = seed;
  }
  nextUint128() {
    if (this.cache === null || this.cacheOffset >= this.cache.length) {
      const input = new Uint8Array(this.seed.length + 8);
      input.set(this.seed, 0);
      writeUint64BE(input, this.seed.length, BigInt(this.counter));
      this.cache = blake313(input);
      this.cacheOffset = 0;
      this.counter += 1;
    }
    const out = readUint128BE2(this.cache, this.cacheOffset);
    this.cacheOffset += 16;
    return out;
  }
};
function writeUint64BE(buf, offset, value) {
  let v = value & (1n << 64n) - 1n;
  for (let i = 7; i >= 0; i--) {
    buf[offset + i] = Number(v & 0xffn);
    v >>= 8n;
  }
}
function writeUint128BE2(buf, offset, value) {
  let v = value & (1n << 128n) - 1n;
  for (let i = 15; i >= 0; i--) {
    buf[offset + i] = Number(v & 0xffn);
    v >>= 8n;
  }
}
function readUint128BE2(buf, offset) {
  let v = 0n;
  for (let i = 0; i < 16; i++) {
    v = v << 8n | BigInt(buf[offset + i] ?? 0);
  }
  return v;
}

// src/lpc/precommit-manager.ts
init_sign();
var PrecommitManager = class {
  opts;
  /** height → blockHashHex → entry. */
  entries = /* @__PURE__ */ new Map();
  /**
   * (height, round) → validatorAddressLower → blockHashHex the validator
   * signed at that specific slot. Key is `${height}#${round}` string.
   *
   * Under LPC's slot-skip, different rounds at the same height have
   * different scheduled proposers and therefore different candidate
   * blocks. An honest validator precommitting the round-0 proposer's
   * block A and then also precommitting the round-1 proposer's block B
   * is NOT double-signing — those are two different consensus slots.
   * Only two precommits at the same (height, round) with different
   * hashes are slashable equivocation.
   */
  validatorChoice = /* @__PURE__ */ new Map();
  latestFinalizedHeight = 0n;
  constructor(opts) {
    this.opts = opts;
  }
  /**
   * Ingest a signed precommit. Idempotent — receiving the same vote twice
   * from the same validator is a benign no-op.
   */
  ingest(vote) {
    if (vote.body.kind !== 1) {
      return { ok: false, finalityReached: false, doubleSign: null, reason: "not-precommit" };
    }
    const height = vote.body.height;
    if (height <= this.latestFinalizedHeight - 128n) {
      return {
        ok: false,
        finalityReached: false,
        doubleSign: null,
        reason: `height ${height} too old (latest finalized ${this.latestFinalizedHeight})`
      };
    }
    const committee = this.opts.getCommitteeAtHeight(height);
    if (committee.length === 0) {
      return { ok: false, finalityReached: false, doubleSign: null, reason: "no-committee" };
    }
    const verifyResult = verifySignedVote(vote);
    if (!verifyResult.ok) {
      return { ok: false, finalityReached: false, doubleSign: null, reason: verifyResult.reason };
    }
    if (vote.body.chainId !== BigInt(this.opts.chainId)) {
      return {
        ok: false,
        finalityReached: false,
        doubleSign: null,
        reason: `wrong chainId: got ${vote.body.chainId} want ${this.opts.chainId}`
      };
    }
    const validatorLower = bytesToHexLower3(vote.body.validator);
    const blockHashHex = bytesToHexLower3(vote.body.blockHash);
    const validatorInSet = committee.some(
      (v) => v.address.toLowerCase() === validatorLower
    );
    if (!validatorInSet) {
      return {
        ok: false,
        finalityReached: false,
        doubleSign: null,
        reason: "validator not in committee"
      };
    }
    const slotKey = `${height.toString()}#${vote.body.round.toString()}`;
    let heightChoices = this.validatorChoice.get(slotKey);
    if (!heightChoices) {
      heightChoices = /* @__PURE__ */ new Map();
      this.validatorChoice.set(slotKey, heightChoices);
    }
    const existingChoice = heightChoices.get(validatorLower);
    if (existingChoice && existingChoice !== blockHashHex) {
      const priorEntry = this.entries.get(height)?.get(existingChoice);
      const priorVote = priorEntry?.signers.get(validatorLower);
      if (priorVote) {
        const evidence = {
          offender: vote.body.validator,
          height,
          blockHashA: priorVote.body.blockHash,
          blockHashB: vote.body.blockHash,
          voteA: priorVote,
          voteB: vote,
          detectedAtMs: this.opts.now()
        };
        this.opts.onDoubleSign?.(evidence);
        return {
          ok: false,
          finalityReached: false,
          doubleSign: evidence,
          reason: "double-sign"
        };
      }
    }
    if (!existingChoice) heightChoices.set(validatorLower, blockHashHex);
    let byHash = this.entries.get(height);
    if (!byHash) {
      byHash = /* @__PURE__ */ new Map();
      this.entries.set(height, byHash);
    }
    let entry = byHash.get(blockHashHex);
    if (!entry) {
      entry = {
        blockHashHex,
        accumulatedStake: 0n,
        signers: /* @__PURE__ */ new Map(),
        finalized: false,
        finalizedAtMs: null
      };
      byHash.set(blockHashHex, entry);
    }
    if (entry.signers.has(validatorLower)) {
      return { ok: true, finalityReached: false, doubleSign: null };
    }
    const stakeStr = committee.find(
      (v) => v.address.toLowerCase() === validatorLower
    )?.stake ?? "0";
    const stake = BigInt(stakeStr);
    entry.signers.set(validatorLower, vote);
    entry.accumulatedStake += stake;
    let finalityReached = false;
    if (!entry.finalized) {
      const threshold = this.quorumThresholdFor(committee);
      if (entry.accumulatedStake >= threshold) {
        entry.finalized = true;
        entry.finalizedAtMs = this.opts.now();
        finalityReached = true;
        if (height > this.latestFinalizedHeight) this.latestFinalizedHeight = height;
        this.opts.onFinalized?.(height, blockHashHex, entry);
        this.prune();
      }
    }
    return { ok: true, finalityReached, doubleSign: null };
  }
  /**
   * Is this height's block finalized (2/3+ stake precommitted)?
   * Returns the finalized block's hash, or null if no block has hit
   * quorum at this height.
   */
  finalizedHashAt(height) {
    const byHash = this.entries.get(height);
    if (!byHash) return null;
    for (const [hashHex, entry] of byHash) {
      if (entry.finalized) return hashHex;
    }
    return null;
  }
  getLatestFinalizedHeight() {
    return this.latestFinalizedHeight;
  }
  /** Retrieve a finality cert (block hash + all signers) for post-finality use. */
  getCert(height, blockHashHex) {
    return this.entries.get(height)?.get(blockHashHex) ?? null;
  }
  /**
   * Snapshot for diagnostics. Returns totals per (height, hash) without
   * exposing internal signer maps.
   */
  snapshot() {
    const out = [];
    for (const [height, byHash] of this.entries) {
      for (const [hashHex, entry] of byHash) {
        out.push({
          height: height.toString(),
          blockHash: hashHex,
          stake: entry.accumulatedStake.toString(),
          signerCount: entry.signers.size,
          finalized: entry.finalized
        });
      }
    }
    return out;
  }
  quorumThresholdFor(committee) {
    let total = 0n;
    for (const v of committee) total += BigInt(v.stake);
    return total * 2n / 3n + 1n;
  }
  /**
   * Prune per-height buckets 128+ heights below latestFinalized to bound
   * memory. Layer 3 checkpoints archive anything older.
   */
  prune() {
    const cutoff = this.latestFinalizedHeight - 128n;
    if (cutoff <= 0n) return;
    for (const height of this.entries.keys()) {
      if (height < cutoff) {
        this.entries.delete(height);
      }
    }
    for (const slotKey of this.validatorChoice.keys()) {
      const hStr = slotKey.split("#")[0];
      if (hStr && BigInt(hStr) < cutoff) {
        this.validatorChoice.delete(slotKey);
      }
    }
  }
};
function bytesToHexLower3(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}

// src/lpc/slot.ts
function slotForHeight(height) {
  if (height < 1n) throw new Error(`slotForHeight: height ${height} < 1`);
  return height - 1n;
}
function expectedProposerAddress(height, committee) {
  if (committee.length === 0) {
    throw new Error(`expectedProposerAddress: empty committee at h=${height}`);
  }
  const slot = slotForHeight(height);
  const idx = Number(slot % BigInt(committee.length));
  return committee[idx];
}
function slotStartUnixMs(slot, cfg) {
  return cfg.slot0StartUnixMs + Number(slot) * cfg.slotDurationMs;
}
function slotGraceEndUnixMs(slot, cfg) {
  return slotStartUnixMs(slot, cfg) + cfg.slotDurationMs + cfg.graceWindowMs;
}
function currentSlot(nowMs, cfg) {
  const elapsed = nowMs - cfg.slot0StartUnixMs;
  if (elapsed < 0) return 0n;
  return BigInt(Math.floor(elapsed / cfg.slotDurationMs));
}

// src/lpc/slot-manager.ts
var SlotManager = class {
  cfg;
  selfAddress;
  getCommittee;
  hooks;
  now;
  setTimer;
  currentSlotNum = -1n;
  cancelStartTimer = null;
  stopped = false;
  constructor(opts) {
    this.cfg = opts.config;
    this.selfAddress = opts.selfAddress;
    this.getCommittee = opts.getCommittee;
    this.hooks = opts.hooks;
    this.now = opts.now ?? (() => Date.now());
    this.setTimer = opts.setTimer ?? ((fn, ms) => {
      const id = setTimeout(fn, ms);
      return () => clearTimeout(id);
    });
  }
  /**
   * Begin tracking slots. Idempotent — safe to call multiple times.
   * On start, immediately fires `onSlotStart` for the current slot so
   * the engine sees "you're behind, catch up" on boot without waiting
   * for the next slot boundary.
   */
  start() {
    if (this.stopped) throw new Error("SlotManager: cannot start after stop");
    this.tick();
  }
  /** Stop firing hooks. Safe to call multiple times. */
  stop() {
    this.stopped = true;
    if (this.cancelStartTimer) {
      this.cancelStartTimer();
      this.cancelStartTimer = null;
    }
  }
  /** Current wall-clock slot (public for RPC snapshot). */
  getCurrentSlot() {
    return currentSlot(this.now(), this.cfg);
  }
  /**
   * Given a height, is THIS validator the expected proposer for its slot?
   * Callers (the engine) use this both when a slot fires locally and
   * when an incoming proposal needs validation.
   */
  isSelfProposerForHeight(height) {
    const committee = this.getCommittee();
    if (committee.length === 0) return false;
    const proposer = expectedProposerAddress(height, committee);
    return bytesEqual5(proposer, this.selfAddress);
  }
  /**
   * Advance to the current slot, fire onSlotStart if new, schedule the
   * onSlotEnd for grace-window close, and re-schedule ourselves for the
   * next slot start. All bookkeeping in one place.
   */
  tick() {
    if (this.stopped) return;
    const nowMs = this.now();
    const slot = currentSlot(nowMs, this.cfg);
    if (slot !== this.currentSlotNum) {
      this.currentSlotNum = slot;
      const height = slot + 1n;
      const isSelfProposer = this.isSelfProposerForHeight(height);
      try {
        this.hooks.onSlotStart(slot, isSelfProposer);
      } catch (err) {
        console.error(`[slot-manager] onSlotStart hook threw:`, err);
      }
      const graceEnd = slotGraceEndUnixMs(slot, this.cfg);
      const graceDelay = Math.max(0, graceEnd - nowMs);
      this.setTimer(() => {
        if (this.stopped) return;
        try {
          this.hooks.onSlotEnd(slot);
        } catch (err) {
          console.error(`[slot-manager] onSlotEnd hook threw:`, err);
        }
      }, graceDelay);
    }
    const nextSlotStart = slotStartUnixMs(slot + 1n, this.cfg);
    const delay = Math.max(1, nextSlotStart - nowMs);
    this.cancelStartTimer = this.setTimer(() => {
      this.cancelStartTimer = null;
      this.tick();
    }, delay);
  }
};
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;
}

// src/lpc/engine.ts
var LPCEngine = class {
  opts;
  precommitMgr;
  slotMgr;
  height = 1n;
  /** Cached committee snapshot at each height. Cleared at prune. */
  committeeCache = /* @__PURE__ */ new Map();
  constructor(opts) {
    this.opts = opts;
    this.precommitMgr = new PrecommitManager({
      chainId: opts.chainId,
      getCommitteeAtHeight: (h) => this.getCommitteeAt(h),
      now: opts.now ?? (() => Date.now()),
      onFinalized: (h, hash) => {
        opts.hooks.onBlockFinalized(h, hash);
        if (h >= this.height) this.height = h + 1n;
      },
      onDoubleSign: opts.hooks.onDoubleSign
    });
    this.slotMgr = new SlotManager({
      config: opts.slotConfig,
      selfAddress: opts.selfAddress,
      getCommittee: () => this.getCommittee().map((v) => hexToBytes5(v.address)),
      hooks: {
        onSlotStart: opts.hooks.onSlotStart,
        onSlotEnd: opts.hooks.onSlotEnd
      },
      now: opts.now,
      setTimer: opts.setTimer
    });
  }
  /**
   * Start the engine at a given height. `height` should be the next
   * height the node expects to vote on (i.e. finalized head + 1 on boot).
   * Kicks the SlotManager into life. Idempotent to re-call at cutover.
   */
  startHeight(height) {
    this.height = height;
    this.slotMgr.start();
  }
  stop() {
    this.slotMgr.stop();
  }
  /**
   * Ingest a signed vote. Precommits go to PrecommitManager. Prevotes
   * are silently dropped — LPC has no prevote phase at Layer 1.
   */
  ingestVote(vote) {
    if (vote.body.kind === 0) {
      return { ok: true, finalityReached: false, doubleSign: null };
    }
    const outcome = this.precommitMgr.ingest(vote);
    if (!outcome.ok) return { ok: false, reason: outcome.reason ?? "unknown" };
    return {
      ok: true,
      finalityReached: outcome.finalityReached,
      doubleSign: outcome.doubleSign
    };
  }
  /** Current height the engine expects the next block for. */
  getHeight() {
    return this.height;
  }
  getFinalizedHeight() {
    return this.precommitMgr.getLatestFinalizedHeight();
  }
  /** Is a given block finalized? Returns its hash or null. */
  finalizedHashAt(height) {
    return this.precommitMgr.finalizedHashAt(height);
  }
  /** Fetch the finality cert (signers + stake) for a given (height, hash). */
  getFinalityCert(height, blockHashHex) {
    return this.precommitMgr.getCert(height, blockHashHex);
  }
  // ==========================================================================
  // API compatibility shims — matches ConsensusEngine's method surface so
  // node.ts can switch engines via a facade. Round-based methods return
  // LPC-appropriate defaults (round is always 0n, no LOCK, no POLKA).
  // ==========================================================================
  /** Round is always 0 in LPC (no rounds inside a height). */
  getRound() {
    return 0n;
  }
  /** No-op in LPC (no round advancement). */
  advanceRound() {
  }
  /** LPC has no phase state — always report a stable "precommit" phase. */
  getPhase() {
    return "precommit";
  }
  /**
   * Deterministic proposer for the current height, matching the slot
   * rotation. Returns null if the committee is empty.
   */
  currentProposer() {
    const committee = this.getCommittee();
    if (committee.length === 0) return null;
    return {
      address: expectedProposerAddress(
        this.height,
        committee.map((v) => hexToBytes5(v.address))
      )
    };
  }
  /** LPC has no POLKA state. Always empty. */
  getPolkasForCurrentHeight() {
    return [];
  }
  /** LPC has no late-precommit buffer (no rounds → no "late"). */
  getLateCommittedSigners() {
    return [];
  }
  /** No LOCK state in LPC. */
  getLockedValue() {
    return null;
  }
  /**
   * Current validator set from the injected getCommittee() closure. Mirrors
   * ConsensusEngine's getter so node.ts can log validator counts uniformly.
   */
  getValidators() {
    return this.getCommittee();
  }
  /**
   * Diagnostic snapshot for RPC. Similar shape to the current engine's
   * snapshot but with slot/finality instead of round/phase.
   */
  snapshot() {
    const committee = this.getCommittee();
    let threshold = 0n;
    for (const v of committee) threshold += BigInt(v.stake);
    threshold = threshold * 2n / 3n + 1n;
    const proposer = committee.length > 0 ? bytesToHex11(
      expectedProposerAddress(
        this.height,
        committee.map((v) => hexToBytes5(v.address))
      )
    ) : null;
    const heightSnapshot = this.precommitMgr.snapshot().filter(
      (s) => BigInt(s.height) === this.height
    );
    return {
      height: this.height.toString(),
      slot: this.slotMgr.getCurrentSlot().toString(),
      finalizedHeight: this.getFinalizedHeight().toString(),
      proposer,
      validatorCount: committee.length,
      quorumThresholdStake: threshold.toString(),
      precommitCounts: heightSnapshot.map((s) => ({
        blockHash: s.blockHash,
        stake: s.stake,
        signerCount: s.signerCount,
        finalized: s.finalized
      }))
    };
  }
  getCommittee() {
    return this.opts.getCommittee();
  }
  getCommitteeAt(height) {
    const cached = this.committeeCache.get(height);
    if (cached) return cached;
    const now = this.opts.getCommittee();
    this.committeeCache.set(height, now);
    if (this.committeeCache.size > 256) {
      const cutoff = height - 128n;
      for (const h of this.committeeCache.keys()) {
        if (h < cutoff) this.committeeCache.delete(h);
      }
    }
    return now;
  }
};
function bytesToHex11(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
function hexToBytes5(hex2) {
  const s = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  const out = new Uint8Array(s.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}

// src/lpc/engine-adapter.ts
var LPCEngineAdapter = class {
  engine;
  chainId;
  doubleSignListeners = [];
  polkaCommitListeners = [];
  evidence = [];
  /**
   * Committee snapshot from the latest startHeight() call. LPC's
   * PrecommitManager fetches this via `getCommitteeAtHeight(h)` which
   * we route to this snapshot. Node.ts sets it once per height at
   * `startHeight({ height, validators })` — matches BFT semantics.
   */
  storedCommittee = [];
  finalityListeners = [];
  /**
   * Slot-skip counter: advances via `advanceRound()` when the scheduled
   * proposer for the current height fails to produce within the timeout
   * window. Each increment rotates `currentProposer()` to the next
   * validator in sorted committee order — `committee[(h-1+skip) mod N]`.
   * Reset to 0 on `startHeight()` (new height starts with the base
   * proposer). This is the mechanism that lets LPC survive an offline
   * validator being the scheduled slot proposer at some height.
   * Convergence across nodes: proposer at effective skip=k signs their
   * proposal with `round=k` in the vote; peers seeing the signed vote
   * for a higher round advance their local skip to match, per the
   * `syncSkipFromObservedVote` bridge below.
   */
  slotSkip = 0n;
  constructor(opts) {
    this.chainId = opts.chainId;
    const userGetCommittee = opts.getCommittee;
    const wrappedGetCommittee = () => {
      if (this.storedCommittee.length > 0) return this.storedCommittee;
      return userGetCommittee();
    };
    opts = { ...opts, getCommittee: wrappedGetCommittee };
    const userDoubleSignHook = opts.hooks.onDoubleSign;
    const userOnFinalized = opts.hooks.onBlockFinalized;
    this.engine = new LPCEngine({
      ...opts,
      chainId: Number(opts.chainId),
      hooks: {
        ...opts.hooks,
        onBlockFinalized: (h, hashHex) => {
          userOnFinalized?.(h, hashHex);
          const listeners = this.finalityListeners.slice();
          for (const l of listeners) {
            try {
              l(h, hashHex);
            } catch {
            }
          }
        },
        onDoubleSign: (ev) => {
          const asBFTEvidence = {
            offender: ev.offender,
            kind: "precommit",
            height: ev.height,
            round: 0n,
            blockHashA: ev.blockHashA,
            blockHashB: ev.blockHashB,
            voteA: ev.voteA,
            voteB: ev.voteB,
            detectedAt: ev.detectedAtMs
          };
          this.evidence.push(asBFTEvidence);
          for (const l of this.doubleSignListeners) l(asBFTEvidence);
          userDoubleSignHook?.(ev);
        }
      }
    });
  }
  /**
   * Subscribe to per-height finality events. Returns an unsubscribe fn.
   * Fires whenever PrecommitManager reaches quorum for a (height, hash).
   */
  onFinalized(listener) {
    this.finalityListeners.push(listener);
    return () => {
      this.finalityListeners = this.finalityListeners.filter((l) => l !== listener);
    };
  }
  /**
   * Wait until (height, blockHashHex) is finalized. Resolves immediately
   * if the engine already has this cert. Rejects with a timeout error
   * if quorum doesn't reach in time.
   *
   * This is the LPC replacement for the BFT engine's `waitForQuorum` —
   * it maps 1:1 to PrecommitManager's per-block finality event.
   */
  waitForFinality(height, blockHashHex, timeoutMs) {
    const target = blockHashHex.toLowerCase();
    if (this.engine.finalizedHashAt(height) === target) return Promise.resolve();
    return new Promise((resolve, reject) => {
      let settled = false;
      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        unsub();
        reject(new Error(`LPC waitForFinality timeout at h=${height} hash=${target.slice(0, 14)}\u2026`));
      }, timeoutMs);
      const listener = (h, hh) => {
        if (settled) return;
        if (h === height && hh.toLowerCase() === target) {
          settled = true;
          clearTimeout(timer);
          unsub();
          resolve();
        }
      };
      const unsub = this.onFinalized(listener);
    });
  }
  // ==========================================================================
  // Direct delegation — LPC has native equivalents
  // ==========================================================================
  ingestVote(vote) {
    const outcome = this.engine.ingestVote(vote);
    if (!outcome.ok) return { ok: false, reason: outcome.reason };
    return {
      ok: true,
      kind: vote.body.kind === 0 ? "prevote" : "precommit",
      duplicate: false,
      doubleSign: outcome.doubleSign !== null,
      evidence: outcome.doubleSign !== null ? this.mapDoubleSign(outcome.doubleSign) : void 0,
      prevoteCount: 0,
      precommitCount: 0,
      quorumReached: outcome.finalityReached
    };
  }
  /**
   * Update slotSkip from an observed vote's round, if that round is
   * higher than our current skip. Node.ts calls this from
   * `ingestConsensusVote` for LPC-mode adapters, so peers converge on
   * the same effective proposer as votes propagate. This is the LPC
   * equivalent of BFT's POLKA-jump round-advance mechanism, minus the
   * prevote/precommit distinction (LPC only has precommits).
   */
  observeVoteRound(round) {
    if (round > this.slotSkip) this.slotSkip = round;
  }
  startHeight(opts) {
    if (opts.validators) {
      const sorted = [...opts.validators].sort(
        (a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase())
      );
      this.storedCommittee = sorted;
    }
    this.slotSkip = 0n;
    this.engine.startHeight(opts.height);
  }
  getHeight() {
    return this.engine.getHeight();
  }
  getValidators() {
    return this.engine.getValidators();
  }
  currentProposer() {
    const committee = this.engine.getValidators();
    if (committee.length === 0) return null;
    const height = this.engine.getHeight();
    const n = BigInt(committee.length);
    const offset = (height - 1n + this.slotSkip) % n;
    const idx = offset < 0n ? offset + n : offset;
    return committee[Number(idx)] ?? null;
  }
  committedHash() {
    return this.engine.finalizedHashAt(this.engine.getHeight() - 1n);
  }
  snapshot() {
    const s = this.engine.snapshot();
    const finalizedHash = this.engine.finalizedHashAt(this.engine.getHeight() - 1n);
    return {
      height: s.height,
      round: "0",
      phase: "precommit",
      proposer: s.proposer,
      validatorCount: s.validatorCount,
      quorumThresholdStake: s.quorumThresholdStake,
      prevoteCounts: [],
      precommitCounts: s.precommitCounts.map((p) => ({
        blockHash: p.blockHash,
        count: p.signerCount,
        stake: p.stake
      })),
      committedBlockHash: finalizedHash
    };
  }
  // ==========================================================================
  // No-op / stub for round-based methods
  // ==========================================================================
  getRound() {
    return this.slotSkip;
  }
  advanceRound() {
    this.slotSkip += 1n;
  }
  getPhase() {
    return "precommit";
  }
  setPhase(_phase) {
  }
  getMaxSeenVoteHeight() {
    return this.engine.getHeight();
  }
  getLastCommittedAtWallMs() {
    return this.lastCommittedAtWallMs;
  }
  lastCommittedAtWallMs = 0;
  getLockedValue() {
    return null;
  }
  hasUnlockingPolka(_since, _hash) {
    return false;
  }
  getPolkasForCurrentHeight() {
    return [];
  }
  stakeThreshold() {
    const committee = this.engine.getValidators();
    let total = 0n;
    for (const v of committee) total += BigInt(v.stake);
    return total * 2n / 3n + 1n;
  }
  getRecentEvidence() {
    return this.evidence;
  }
  drainEvidence() {
    const drained = this.evidence;
    this.evidence = [];
    return drained;
  }
  // ==========================================================================
  // Late-precommit / commit-from-polka surface — LPC does not use these paths
  // ==========================================================================
  getCommittedSigners(blockHash) {
    const hashHex = bytesToHex12(blockHash).toLowerCase();
    const currentHeight = this.engine.getHeight();
    const lowerBound = currentHeight > 8n ? currentHeight - 8n : 0n;
    for (let h = currentHeight - 1n; h >= lowerBound; h -= 1n) {
      if (this.engine.finalizedHashAt(h) === hashHex) {
        const cert = this.engine.getFinalityCert(h, hashHex);
        if (cert) return Array.from(cert.signers.keys());
      }
      if (h === 0n) break;
    }
    return [];
  }
  getLateCommittedSigners() {
    return [];
  }
  getCommittedPrecommits() {
    return null;
  }
  detectPrecommitPolka() {
    return null;
  }
  commitFromPrecommitPolka() {
  }
  waitForQuorum() {
    return Promise.resolve();
  }
  commitResultFor(blockHash) {
    const hashHex = bytesToHex12(blockHash).toLowerCase();
    const committed = this.engine.finalizedHashAt(this.engine.getHeight() - 1n) === hashHex;
    return {
      committed,
      count: 0,
      stake: 0n,
      threshold: this.stakeThreshold()
    };
  }
  getCurrentRoundVotes() {
    return [];
  }
  // ==========================================================================
  // Hooks — LPC delegates some; others no-op
  // ==========================================================================
  setPersistCertHook(_fn) {
  }
  setBehindDetectedHook(_fn) {
  }
  setRoundAdvancedHook(_fn) {
  }
  onDoubleSign(listener) {
    this.doubleSignListeners.push(listener);
    return () => {
      this.doubleSignListeners = this.doubleSignListeners.filter((l) => l !== listener);
    };
  }
  onPolkaCommit(listener) {
    this.polkaCommitListeners.push(listener);
    return () => {
      this.polkaCommitListeners = this.polkaCommitListeners.filter((l) => l !== listener);
    };
  }
  restoreCommittedPrecommits(_opts) {
  }
  syncCommittedHashFromBlockStore(_opts) {
  }
  // ==========================================================================
  // Test / diagnostic surface
  // ==========================================================================
  stakeOf(address) {
    const v = this.engine.getValidators().find(
      (v2) => v2.address.toLowerCase() === address.toLowerCase()
    );
    return v ? BigInt(v.stake) : 0n;
  }
  stop() {
    this.engine.stop();
  }
  /** Escape hatch for tests + advanced integration. */
  getLPCEngine() {
    return this.engine;
  }
  mapDoubleSign(ev) {
    return {
      offender: ev.offender,
      kind: "precommit",
      height: ev.height,
      round: 0n,
      blockHashA: ev.blockHashA,
      blockHashB: ev.blockHashB,
      voteA: ev.voteA,
      voteB: ev.voteB,
      detectedAt: ev.detectedAtMs
    };
  }
};
function bytesToHex12(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}

// src/consensus/engine-factory.ts
function makeEngine(opts) {
  const useLPC = process.env.USE_LPC_ENGINE === "1" || process.env.USE_LPC_ENGINE?.toLowerCase() === "true";
  if (useLPC) {
    console.log("[engine-factory] USE_LPC_ENGINE=1 \u2014 instantiating LPCEngineAdapter");
    return new LPCEngineAdapter({
      chainId: opts.chainId,
      slotConfig: opts.lpc.slotConfig,
      selfAddress: opts.lpc.selfAddress,
      getCommittee: opts.lpc.getCommittee,
      hooks: opts.lpc.hooks,
      now: opts.lpc.now,
      setTimer: opts.lpc.setTimer
    });
  }
  return new ConsensusEngine(opts.chainId);
}
function isLPCEngine() {
  return process.env.USE_LPC_ENGINE === "1" || process.env.USE_LPC_ENGINE?.toLowerCase() === "true";
}

// src/node/block-store.ts
import { ClassicLevel } from "classic-level";
import { blake3 as blake314 } from "@asentum/crypto";
import {
  BlockHeaderSchema as BlockHeaderSchema3,
  BlockSchema as BlockSchema2
} from "@asentum/types";
var BLOCK_PREFIX = new Uint8Array([98, 58]);
var METADATA_PREFIX = new Uint8Array([109, 58]);
var RECEIPT_PREFIX = new Uint8Array([114, 58]);
var RECEIPTS_BY_BLOCK_PREFIX = new Uint8Array([82, 58]);
var BLOCK_HASH_PREFIX = new Uint8Array([66, 72, 58]);
var LATEST_HEADER_KEY = concat(METADATA_PREFIX, utf8("latestHeader"));
var LATEST_HEIGHT_KEY = concat(METADATA_PREFIX, utf8("latestHeight"));
var BlockStore = class {
  db;
  constructor(path3) {
    this.db = new ClassicLevel(path3, {
      keyEncoding: "view",
      valueEncoding: "view"
    });
  }
  async open() {
    await this.db.open();
  }
  async close() {
    if (this.db.status === "open" || this.db.status === "opening") {
      await this.db.close();
    }
  }
  async putBlock(block) {
    const key6 = blockKey(block.header.number);
    const value = BlockSchema2.serialize(block);
    const headerBytes = BlockHeaderSchema3.serialize(block.header);
    const heightBytes = u64be(block.header.number);
    const blockHash = blake314(headerBytes);
    const batch = this.db.batch();
    batch.put(key6, value);
    batch.put(LATEST_HEADER_KEY, headerBytes);
    batch.put(LATEST_HEIGHT_KEY, heightBytes);
    batch.put(blockHashKey(blockHash), heightBytes);
    await batch.write();
  }
  async getBlockByNumber(number) {
    try {
      const bytes = await this.db.get(blockKey(number));
      return BlockSchema2.deserialize(new Uint8Array(bytes));
    } catch (err) {
      if (isNotFound(err)) return null;
      throw err;
    }
  }
  /**
   * Resolve a block by its BLAKE3-over-SSZ-header hash.
   * Uses the hash → number index written by putBlock. Returns null
   * if the hash is unknown.
   */
  async getBlockByHash(hash) {
    if (hash.length !== 32) return null;
    try {
      const numBytes = await this.db.get(blockHashKey(hash));
      const number = u64beDecode(new Uint8Array(numBytes));
      return this.getBlockByNumber(number);
    } catch (err) {
      if (isNotFound(err)) return null;
      throw err;
    }
  }
  async getLatestHeader() {
    try {
      const bytes = await this.db.get(LATEST_HEADER_KEY);
      return BlockHeaderSchema3.deserialize(new Uint8Array(bytes));
    } catch (err) {
      if (isNotFound(err)) return null;
      throw err;
    }
  }
  async getLatestHeight() {
    try {
      const bytes = await this.db.get(LATEST_HEIGHT_KEY);
      return u64beDecode(new Uint8Array(bytes));
    } catch (err) {
      if (isNotFound(err)) return null;
      throw err;
    }
  }
  // -------------------------------------------------------------------------
  // Receipts
  // -------------------------------------------------------------------------
  /**
   * Persist a batch of receipts for a single block. Receipts are stored
   * both by tx hash (for fast lookup by hash) and by (blockNumber, txIndex)
   * (for fast "all receipts for block N" iteration).
   */
  async putReceipts(blockNumber, receipts) {
    if (receipts.length === 0) return;
    const batch = this.db.batch();
    for (const receipt of receipts) {
      const bytes = ReceiptSchema.serialize(receipt);
      batch.put(receiptByHashKey(receipt.txHash), bytes);
      batch.put(receiptByBlockKey(blockNumber, receipt.txIndex), receipt.txHash);
    }
    await batch.write();
  }
  /** Retrieve a single receipt by its transaction hash. */
  async getReceiptByTxHash(txHash3) {
    try {
      const bytes = await this.db.get(receiptByHashKey(txHash3));
      return ReceiptSchema.deserialize(new Uint8Array(bytes));
    } catch (err) {
      if (isNotFound(err)) return null;
      throw err;
    }
  }
  /** Retrieve all receipts for a block in index order. */
  async getReceiptsByBlock(blockNumber) {
    const prefix = receiptByBlockPrefix(blockNumber);
    const end = incrementLastByte(prefix);
    const results = [];
    for await (const [, value] of this.db.iterator({ gte: prefix, lt: end })) {
      const txHashBytes = value instanceof Uint8Array ? new Uint8Array(value) : value;
      const receipt = await this.getReceiptByTxHash(txHashBytes);
      if (receipt) results.push(receipt);
    }
    return results;
  }
};
function blockKey(number) {
  const out = new Uint8Array(BLOCK_PREFIX.length + 8);
  out.set(BLOCK_PREFIX, 0);
  out.set(u64be(number), BLOCK_PREFIX.length);
  return out;
}
function blockHashKey(hash) {
  const out = new Uint8Array(BLOCK_HASH_PREFIX.length + hash.length);
  out.set(BLOCK_HASH_PREFIX, 0);
  out.set(hash, BLOCK_HASH_PREFIX.length);
  return out;
}
function u64be(n) {
  const out = new Uint8Array(8);
  let v = n;
  for (let i = 7; i >= 0; i--) {
    out[i] = Number(v & 0xffn);
    v >>= 8n;
  }
  return out;
}
function u64beDecode(bytes) {
  let v = 0n;
  for (let i = 0; i < 8; i++) {
    v = v << 8n | BigInt(bytes[i] ?? 0);
  }
  return v;
}
function utf8(s) {
  return new TextEncoder().encode(s);
}
function concat(a, b) {
  const out = new Uint8Array(a.length + b.length);
  out.set(a, 0);
  out.set(b, a.length);
  return out;
}
function receiptByHashKey(txHash3) {
  const out = new Uint8Array(RECEIPT_PREFIX.length + txHash3.length);
  out.set(RECEIPT_PREFIX, 0);
  out.set(txHash3, RECEIPT_PREFIX.length);
  return out;
}
function receiptByBlockKey(blockNumber, txIndex) {
  const out = new Uint8Array(RECEIPTS_BY_BLOCK_PREFIX.length + 8 + 4);
  out.set(RECEIPTS_BY_BLOCK_PREFIX, 0);
  out.set(u64be(blockNumber), RECEIPTS_BY_BLOCK_PREFIX.length);
  const idxBytes = new Uint8Array(4);
  idxBytes[0] = txIndex >>> 24 & 255;
  idxBytes[1] = txIndex >>> 16 & 255;
  idxBytes[2] = txIndex >>> 8 & 255;
  idxBytes[3] = txIndex & 255;
  out.set(idxBytes, RECEIPTS_BY_BLOCK_PREFIX.length + 8);
  return out;
}
function receiptByBlockPrefix(blockNumber) {
  const out = new Uint8Array(RECEIPTS_BY_BLOCK_PREFIX.length + 8);
  out.set(RECEIPTS_BY_BLOCK_PREFIX, 0);
  out.set(u64be(blockNumber), RECEIPTS_BY_BLOCK_PREFIX.length);
  return out;
}
function incrementLastByte(bytes) {
  const out = new Uint8Array(bytes);
  for (let i = out.length - 1; i >= 0; i--) {
    if (out[i] !== 255) {
      out[i] = (out[i] ?? 0) + 1;
      return out;
    }
    out[i] = 0;
  }
  return new Uint8Array([...out, 0]);
}
function isNotFound(err) {
  if (err === null || typeof err !== "object") return false;
  return err.code === "LEVEL_NOT_FOUND";
}

// src/consensus/last-commit-persist.ts
import { promises as fs2 } from "fs";
import * as path from "path";
var CERT_FILE_NAME = "last-commit-cert.json";
function bytesToHex13(b) {
  let out = "0x";
  for (let i = 0; i < b.length; i++) {
    out += b[i].toString(16).padStart(2, "0");
  }
  return out;
}
function hexToBytes6(hex2) {
  const clean = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  const out = new Uint8Array(clean.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}
function encodeVote(vote) {
  return {
    publicKeyHex: bytesToHex13(vote.publicKey),
    signatureHex: bytesToHex13(vote.signature),
    body: {
      kind: vote.body.kind,
      chainId: vote.body.chainId.toString(),
      height: vote.body.height.toString(),
      round: vote.body.round.toString(),
      blockHashHex: bytesToHex13(vote.body.blockHash),
      validatorHex: bytesToHex13(vote.body.validator)
    }
  };
}
function decodeVote(p) {
  return {
    publicKey: hexToBytes6(p.publicKeyHex),
    signature: hexToBytes6(p.signatureHex),
    body: {
      kind: p.body.kind,
      chainId: BigInt(p.body.chainId),
      height: BigInt(p.body.height),
      round: BigInt(p.body.round),
      blockHash: hexToBytes6(p.body.blockHashHex),
      validator: hexToBytes6(p.body.validatorHex)
    }
  };
}
var persistCertStats = {
  writeSuccessCount: 0,
  writeFailureCount: 0,
  lastFailureAtMs: 0,
  lastFailureReason: ""
};
async function persistCert(dataDir, cert) {
  const target = path.join(dataDir, CERT_FILE_NAME);
  const tmp = target + ".tmp";
  try {
    const body = JSON.stringify(cert);
    const fh = await fs2.open(tmp, "w");
    try {
      await fh.writeFile(body);
      await fh.sync();
    } finally {
      await fh.close();
    }
    await fs2.rename(tmp, target);
    persistCertStats.writeSuccessCount += 1;
  } catch (err) {
    persistCertStats.writeFailureCount += 1;
    persistCertStats.lastFailureAtMs = Date.now();
    persistCertStats.lastFailureReason = err instanceof Error ? err.message : String(err);
    console.error(
      "[last-commit-persist] write failed \u2014 engine will fall back to empty cart on next restart:",
      err
    );
  }
}
async function loadCert(dataDir) {
  const target = path.join(dataDir, CERT_FILE_NAME);
  try {
    const raw = await fs2.readFile(target, "utf-8");
    const parsed = JSON.parse(raw);
    if (parsed.version !== 1) {
      console.warn(
        `[last-commit-persist] unknown version ${parsed.version} in ${target} \u2014 ignoring`
      );
      return null;
    }
    if (!parsed.blockHashHex || !Array.isArray(parsed.votes)) {
      console.warn(`[last-commit-persist] malformed cert file \u2014 ignoring`);
      return null;
    }
    return parsed;
  } catch (err) {
    const code = err.code;
    if (code === "ENOENT") return null;
    console.warn("[last-commit-persist] failed to load cert:", err);
    return null;
  }
}

// src/consensus/liveness-persist.ts
import { promises as fs3 } from "fs";
import * as path2 from "path";
var LIVENESS_FILE_NAME = "liveness-state.json";
async function persistLiveness(dataDir, state) {
  const target = path2.join(dataDir, LIVENESS_FILE_NAME);
  const tmp = target + ".tmp";
  try {
    await fs3.mkdir(dataDir, { recursive: true });
    const body = JSON.stringify(state);
    const fh = await fs3.open(tmp, "w");
    try {
      await fh.writeFile(body);
      await fh.sync();
    } finally {
      await fh.close();
    }
    await fs3.rename(tmp, target);
  } catch (err) {
    console.error(
      "[liveness-persist] write failed \u2014 node will re-bootstrap from block walk on next restart:",
      err
    );
  }
}
async function loadLiveness(dataDir) {
  const target = path2.join(dataDir, LIVENESS_FILE_NAME);
  try {
    const raw = await fs3.readFile(target, "utf-8");
    const parsed = JSON.parse(raw);
    if (parsed.version !== 1) {
      console.warn(
        `[liveness-persist] unknown version ${parsed.version} in ${target} \u2014 ignoring`
      );
      return null;
    }
    if (!Array.isArray(parsed.entries) || typeof parsed.atHeight !== "string") {
      console.warn("[liveness-persist] malformed liveness file \u2014 ignoring");
      return null;
    }
    return parsed;
  } catch (err) {
    const code = err.code;
    if (code === "ENOENT") return null;
    console.warn("[liveness-persist] failed to load liveness state:", err);
    return null;
  }
}

// src/net/v3-pull.ts
init_v3();
function stickyAttemptOrder(sticky, peerCount) {
  if (peerCount <= 0) return [];
  const first = (sticky % peerCount + peerCount) % peerCount;
  if (peerCount === 1) return [first];
  return [first, (first + 1) % peerCount];
}
function nextStickyIndex(sticky, peerCount, attempts) {
  if (peerCount <= 0) return 0;
  const fresh = attempts.find((a) => a.outcome === "fresh");
  if (fresh) return fresh.index % peerCount;
  if (attempts.length > 0 && attempts.every((a) => a.outcome === "unreachable")) {
    return ((sticky % peerCount + peerCount) % peerCount + 1) % peerCount;
  }
  return (sticky % peerCount + peerCount) % peerCount;
}
function slotIndexAtTime(slot0UnixMs, slotDurationMs, unixMs) {
  if (slotDurationMs <= 0) return 0n;
  if (unixMs < slot0UnixMs) return 0n;
  return BigInt(Math.floor((unixMs - slot0UnixMs) / slotDurationMs));
}
function slotsUntilOwnLeaderSlot(currentSlot2, selfIndex, committeeSize) {
  if (committeeSize <= 0) return null;
  if (!Number.isInteger(selfIndex) || selfIndex < 0 || selfIndex >= committeeSize) return null;
  if (currentSlot2 < 0n) return null;
  const cur = Number(currentSlot2 % BigInt(committeeSize));
  return ((selfIndex - cur) % committeeSize + committeeSize) % committeeSize;
}
function slotPrefetchDecision(args) {
  const currentSlot2 = slotIndexAtTime(args.slot0UnixMs, args.slotDurationMs, args.nowUnixMs);
  const distanceSlots = slotsUntilOwnLeaderSlot(currentSlot2, args.selfIndex, args.committeeSize);
  if (distanceSlots === null || args.lookaheadSlots < 0) {
    return { active: false, distanceSlots, currentSlot: currentSlot2 };
  }
  return { active: distanceSlots <= args.lookaheadSlots, distanceSlots, currentSlot: currentSlot2 };
}
function fullAttemptOrder(sticky, peerCount) {
  if (peerCount <= 0) return [];
  const first = (sticky % peerCount + peerCount) % peerCount;
  const order = [];
  for (let i = 0; i < peerCount; i++) order.push((first + i) % peerCount);
  return order;
}
var V3PullService = class _V3PullService {
  stopped = false;
  started = false;
  /** Sticky preferred fetch peer (see stickyAttemptOrder). */
  stickyPeerIndex = 0;
  /** #293 backoff: consecutive all-peer fetch failures. Reset on any success. */
  consecutiveFetchFails = 0;
  static MAX_BACKOFF_MS = 3e4;
  /**
   * #294-style poison guard: the highest height we have already precommitted,
   * so we never re-sign for a height we've handled even if the peer keeps
   * serving it (the self-vote-record would refuse anyway, but this avoids the
   * churn of re-hitting it every tick).
   */
  lastPrecommittedHeight = 0n;
  /**
   * Locally-owned climb cursor (FIX 2). A lone HTTP external's LOCAL
   * finalityStore never reaches quorum (it never pulls peer precommits back),
   * so getV3FinalityHead() is pinned and cannot drive the cursor — keying off
   * it precommits ONE height then idles forever. Instead we own the cursor: we
   * seed it from local finality + 1 on first use, then advance it +1 after each
   * height we finish (precommit accepted, or nothing-to-do like not-in-committee
   * / equivocation-guard). The baked's finalized tip paces us naturally: when we
   * ask for a height the baked hasn't reached, GET /v3/proposal returns 204 and
   * we idle without advancing, then retry the SAME height next tick. So the
   * cursor tracks the baked tip upward instead of our stuck local finality.
   */
  nextHeight = 0n;
  // === Slot-aware pre-fetch state (see module doc above). All of it is inert
  // unless ASENTUM_SLOT_PREFETCH=1 — the default path never touches it.
  prefetchEnabled;
  prefetchFastMs;
  prefetchLookaheadSlots;
  /** True while inside the anticipation window; read by fetchProposal to hedge. */
  prefetchActive = false;
  /** Memoized committee (size + own index) for the epoch of head+1. */
  prefetchCommittee = null;
  /** Throttle for the proactive tip SYNC_REQUEST broadcast. */
  lastTipRequestAt = 0;
  static TIP_REQUEST_THROTTLE_MS = 400;
  /** Heights above head to ask for; peers skip heights they don't hold. */
  static TIP_REQUEST_SPAN = 16n;
  opts;
  constructor(opts) {
    if (opts.peerUrls.length === 0) {
      throw new Error("V3PullService requires at least one peer URL (ASENTUM_PEER_RPC)");
    }
    this.opts = {
      peerUrls: opts.peerUrls.map((u) => u.replace(/\/+$/, "")),
      node: opts.node,
      pollIntervalMs: opts.pollIntervalMs ?? Number(process.env.ASENTUM_PULL_V3_MS ?? "300"),
      idleIntervalMs: opts.idleIntervalMs ?? Number(process.env.ASENTUM_PULL_V3_IDLE_MS ?? "500"),
      timeoutMs: opts.timeoutMs ?? 2e3,
      log: opts.log ?? ((m) => console.log(`[v3-pull] ${m}`))
    };
    this.prefetchEnabled = process.env.ASENTUM_SLOT_PREFETCH === "1";
    this.prefetchFastMs = Number(process.env.ASENTUM_SLOT_PREFETCH_MS ?? "75");
    this.prefetchLookaheadSlots = Number(process.env.ASENTUM_SLOT_PREFETCH_SLOTS ?? "2");
    if (this.prefetchEnabled) {
      this.opts.log(
        `slot-prefetch ON: fastPoll=${this.prefetchFastMs}ms lookahead=${this.prefetchLookaheadSlots} slot(s)`
      );
    }
  }
  /** Run forever until stop(). Resolves only on stop. Idempotent start. */
  async start() {
    if (this.started) return;
    this.started = true;
    this.opts.log(
      `pull-mode active: ${this.opts.peerUrls.length} peer(s), poll ${this.opts.pollIntervalMs}ms`
    );
    while (!this.stopped) {
      let idle = false;
      try {
        idle = await this.tick();
      } catch (err) {
        this.opts.log(`tick failed: ${String(err instanceof Error ? err.message : err)}`);
      }
      try {
        await this.catchupStep();
      } catch (err) {
        this.opts.log(`catchup failed: ${String(err instanceof Error ? err.message : err)}`);
      }
      let waitMs = idle ? this.opts.idleIntervalMs : this.opts.pollIntervalMs;
      if (this.consecutiveFetchFails > 0) {
        const factor = Math.min(1.5 ** this.consecutiveFetchFails, 400);
        waitMs = Math.min(
          this.opts.pollIntervalMs * factor,
          _V3PullService.MAX_BACKOFF_MS
        );
        if (this.consecutiveFetchFails === this.opts.peerUrls.length * 5) {
          this.opts.log(
            `all ${this.opts.peerUrls.length} peer(s) unreachable, backing off to ${Math.round(waitMs / 1e3)}s intervals`
          );
        }
      } else if (this.prefetchEnabled) {
        if (await this.updatePrefetchWindow()) {
          waitMs = Math.min(waitMs, this.prefetchFastMs);
        }
      }
      await sleep(waitMs);
    }
  }
  /** Idempotent. Gracefully exit the loop. */
  stop() {
    this.stopped = true;
  }
  /**
   * One poll/apply/precommit step. Returns true when caught up (no work this
   * tick — baked has not finalized our target height yet), false when we did
   * work (so the loop keeps the fast cadence).
   */
  async tick() {
    const head = this.opts.node.getV3ChainHead();
    if (!head) return true;
    if (this.nextHeight === 0n) {
      this.nextHeight = head.number + 1n;
    }
    if (this.nextHeight > head.number + 1n) {
      this.nextHeight = head.number + 1n;
    }
    const targetHeight = this.nextHeight;
    if (targetHeight <= this.lastPrecommittedHeight) {
      this.nextHeight = this.lastPrecommittedHeight + 1n;
      return false;
    }
    const proposal = await this.fetchProposal(targetHeight);
    if (process.env.ASENTUM_DEBUG_CATCHUP === "1")
      this.opts.log(
        `tick target=${targetHeight} head=${head.number} proposal=${proposal === "unreachable" ? "unreachable" : proposal === null ? "null(204)" : "GOT"}`
      );
    if (proposal === "unreachable") return false;
    if (proposal === null) return true;
    let auraHash;
    let number;
    try {
      const msg = decodeMessage(proposal);
      if (msg.type !== "block-with-body") {
        this.opts.log(`unexpected frame type ${msg.type} at h=${targetHeight}, dropping`);
        return false;
      }
      number = msg.header.number;
      if (number !== targetHeight) {
        return false;
      }
      await this.opts.node.applyV3PulledBlock(msg.header, msg.bodyBytes);
      auraHash = this.opts.node.hashAuraHeader(msg.header);
    } catch (err) {
      this.opts.log(`invalid proposal at h=${targetHeight}: ${String(err instanceof Error ? err.message : err)}`);
      return false;
    }
    const epoch = this.opts.node.epochOfHeight(targetHeight);
    const self = this.opts.node.getSelfValidatorAddress();
    if (!self) return false;
    let inCommittee = false;
    try {
      const committee = await this.opts.node.getDeterministicCommitteeForEpoch(epoch);
      const selfHex = toHex2(self).toLowerCase();
      inCommittee = committee.some((v) => v.address.toLowerCase() === selfHex);
    } catch (err) {
      this.opts.log(`committee check failed at h=${targetHeight}: ${String(err instanceof Error ? err.message : err)}`);
      return false;
    }
    if (!inCommittee) {
      this.lastPrecommittedHeight = targetHeight;
      this.nextHeight = targetHeight + 1n;
      return false;
    }
    const signed = await this.opts.node.signAndRecordV3Precommit({ hash: auraHash, number });
    if (!signed) {
      this.lastPrecommittedHeight = targetHeight;
      this.nextHeight = targetHeight + 1n;
      return false;
    }
    const accepted = await this.postPrecommit(signed);
    if (accepted) {
      this.lastPrecommittedHeight = targetHeight;
      this.nextHeight = targetHeight + 1n;
      this.opts.log(`precommitted h=${targetHeight} hash=${toHex2(auraHash).slice(0, 12)} (accepted)`);
    } else {
      this.opts.log(
        `precommitted h=${targetHeight} hash=${toHex2(auraHash).slice(0, 12)} but no peer accepted \u2014 will retry`
      );
    }
    return false;
  }
  /**
   * Evaluate the slot-anticipation window (ASENTUM_SLOT_PREFETCH=1 only).
   * Returns true while inside the window, having (throttled) broadcast the
   * proactive tip SYNC_REQUEST. Every failure path degrades to "window
   * closed" — the prefetch is a scheduling heuristic and must never break the
   * pull loop, so this method swallows all errors.
   */
  async updatePrefetchWindow() {
    try {
      const node = this.opts.node;
      const timing = node.getV3SlotTiming();
      const head = node.getV3ChainHead();
      const self = node.getSelfValidatorAddress();
      if (!timing || !head || !self) {
        this.prefetchActive = false;
        return false;
      }
      const epoch = node.epochOfHeight(head.number + 1n);
      if (!this.prefetchCommittee || this.prefetchCommittee.epoch !== epoch) {
        const committee = await node.getDeterministicCommitteeForEpoch(epoch);
        const selfHex = toHex2(self).toLowerCase();
        this.prefetchCommittee = {
          epoch,
          size: committee.length,
          selfIndex: committee.findIndex((v) => v.address.toLowerCase() === selfHex)
        };
        this.opts.log(
          `slot-prefetch: epoch=${epoch} committee=${this.prefetchCommittee.size} selfIndex=${this.prefetchCommittee.selfIndex}`
        );
      }
      const decision = slotPrefetchDecision({
        nowUnixMs: Date.now(),
        slot0UnixMs: timing.slot0UnixMs,
        slotDurationMs: timing.slotDurationMs,
        selfIndex: this.prefetchCommittee.selfIndex,
        committeeSize: this.prefetchCommittee.size,
        lookaheadSlots: this.prefetchLookaheadSlots
      });
      if (decision.active && !this.prefetchActive) {
        this.opts.log(
          `slot-prefetch window OPEN: own slot in ${decision.distanceSlots} slot(s) (slot=${decision.currentSlot} head=${head.number})`
        );
      }
      this.prefetchActive = decision.active;
      if (decision.active) {
        const now = Date.now();
        if (now - this.lastTipRequestAt >= _V3PullService.TIP_REQUEST_THROTTLE_MS) {
          this.lastTipRequestAt = now;
          node.requestV3BlockRange(head.number + 1n, head.number + _V3PullService.TIP_REQUEST_SPAN);
        }
      }
      return decision.active;
    } catch (err) {
      this.prefetchActive = false;
      this.opts.log(`slot-prefetch window check failed: ${String(err instanceof Error ? err.message : err)}`);
      return false;
    }
  }
  /**
   * GET /v3/proposal?height=H — sticky peer first, at most one hedged fallback
   * (see stickyAttemptOrder / nextStickyIndex). Returns:
   *   - Uint8Array wire frame on success,
   *   - null on 204 (baked not at H yet) or any soft/non-2xx,
   *   - 'unreachable' when EVERY attempt failed to reach a peer.
   * #293 backoff counting is per-TICK, not per-attempt: one bump when no
   * attempt reached a peer, reset when any did — same rate as the old
   * one-attempt-per-tick rotation, so the all-peers-unreachable threshold
   * (=== peerUrls.length * 5) still fires exactly.
   */
  async fetchProposal(height) {
    const attempts = [];
    let frame = null;
    const attemptOrder = this.prefetchActive ? fullAttemptOrder(this.stickyPeerIndex, this.opts.peerUrls.length) : stickyAttemptOrder(this.stickyPeerIndex, this.opts.peerUrls.length);
    for (const index of attemptOrder) {
      const res = await this.fetchProposalFrom(index, height);
      const outcome = res === "unreachable" ? "unreachable" : res === null ? "not-yet" : "fresh";
      attempts.push({ index, outcome });
      if (outcome === "fresh") {
        frame = res;
        break;
      }
    }
    const next = nextStickyIndex(this.stickyPeerIndex, this.opts.peerUrls.length, attempts);
    if (next !== this.stickyPeerIndex) {
      this.opts.log(`sticky fetch peer #${this.stickyPeerIndex} -> #${next} (${this.opts.peerUrls[next]})`);
      this.stickyPeerIndex = next;
    }
    if (attempts.some((a) => a.outcome !== "unreachable")) this.consecutiveFetchFails = 0;
    else this.consecutiveFetchFails++;
    if (frame) return frame;
    return attempts.every((a) => a.outcome === "unreachable") ? "unreachable" : null;
  }
  /** One fetch attempt against one peer. No side effects on sticky/backoff. */
  async fetchProposalFrom(peerIndex, height) {
    const peer = this.opts.peerUrls[peerIndex];
    const url = `${peer}/v3/proposal?height=${height.toString()}`;
    try {
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), this.opts.timeoutMs);
      try {
        const res = await fetch(url, { signal: ctrl.signal });
        const testFetchDelayMs = Number(process.env.ASENTUM_TEST_FETCH_DELAY_MS ?? "0");
        if (Number.isFinite(testFetchDelayMs) && testFetchDelayMs > 0) await sleep(testFetchDelayMs);
        if (res.status === 204) return null;
        if (!res.ok) return null;
        const body = await res.json();
        if (!body.available || !body.wire) return null;
        return hexToBytes10(body.wire);
      } finally {
        clearTimeout(t);
      }
    } catch {
      return "unreachable";
    }
  }
  /**
   * #418: one finality-catchup step. Fetch the committed cert for our next
   * un-finalized height from a baked and apply it to advance our own finalized
   * head. Best-effort; retries the same height until the baked serves it (204
   * until finalized there) and the block is locally present.
   */
  async catchupStep() {
    const debug = process.env.ASENTUM_DEBUG_CATCHUP === "1";
    const finalized = this.opts.node.getV3Heads().finalized;
    const forkHead = this.opts.node.getV3ChainHead()?.number ?? finalized;
    const peerFinalized = await this.fetchPeerFinalized();
    if (peerFinalized === null) {
      if (debug) this.opts.log(`catchup peer-heads unreachable; finalized=${finalized} fork=${forkHead}`);
      return;
    }
    const target = peerFinalized < forkHead ? peerFinalized : forkHead;
    if (target <= finalized) {
      if (debug)
        this.opts.log(`catchup up-to-date finalized=${finalized} fork=${forkHead} peer=${peerFinalized}`);
      return;
    }
    const commit = await this.fetchCommit(target);
    if (!commit) {
      if (debug)
        this.opts.log(
          `catchup target=${target} fetch=null (204 below peer window / unreachable); finalized=${finalized} fork=${forkHead} peer=${peerFinalized}`
        );
      return;
    }
    const res = await this.opts.node.applyV3FinalityCommit(commit);
    if (debug)
      this.opts.log(
        `catchup target=${target} fetched cert (target=${commit.target_number}) -> applied=${res.applied} finalized=${res.finalized} reason=${res.reason ?? "-"}`
      );
  }
  /**
   * #418: the peer's finalized head via GET /v3/heads, used to clamp the
   * skip-ahead finality target so we never ask for a cert above what the peer
   * has finalized (which would 204 forever in steady-state finality lag).
   * Returns null when no peer answers. Sticky-peer-first, one hedged fallback.
   */
  async fetchPeerFinalized() {
    const order = stickyAttemptOrder(this.stickyPeerIndex, this.opts.peerUrls.length);
    for (const index of order) {
      const peer = this.opts.peerUrls[index];
      try {
        const ctrl = new AbortController();
        const t = setTimeout(() => ctrl.abort(), this.opts.timeoutMs);
        try {
          const res = await fetch(`${peer}/v3/heads`, { signal: ctrl.signal });
          if (!res.ok) continue;
          const body = await res.json();
          if (body.finalized == null) continue;
          return BigInt(body.finalized);
        } finally {
          clearTimeout(t);
        }
      } catch {
        continue;
      }
    }
    return null;
  }
  /** Fetch a finalized commit cert (GET /v3/commit) from the first peer that
   * serves it. Returns null on 204 (not finalized yet) or all-peer failure. */
  async fetchCommit(height) {
    const order = stickyAttemptOrder(this.stickyPeerIndex, this.opts.peerUrls.length);
    for (const index of order) {
      const peer = this.opts.peerUrls[index];
      try {
        const ctrl = new AbortController();
        const t = setTimeout(() => ctrl.abort(), this.opts.timeoutMs);
        try {
          const res = await fetch(`${peer}/v3/commit?height=${height.toString()}`, { signal: ctrl.signal });
          if (res.status === 204) return null;
          if (!res.ok) continue;
          const body = await res.json();
          return body.commit ?? null;
        } finally {
          clearTimeout(t);
        }
      } catch {
        continue;
      }
    }
    return null;
  }
  /**
   * POST /v3/precommit to every peer (best-effort; first accept is enough, but
   * we broadcast to all so any reachable baked can fold the bit). The precommit
   * body travels as hex of the v3 wire VOTE frame — the SAME encoding the ANT
   * VOTE path uses — so the server decodes it with the identical codec.
   */
  async postPrecommit(vote) {
    const testDelayMs = Number(process.env.ASENTUM_TEST_VOTE_DELAY_MS ?? "0");
    if (Number.isFinite(testDelayMs) && testDelayMs > 0) await sleep(testDelayMs);
    const wireHex = toHex2(this.opts.node.encodeV3VoteFrame(vote));
    const payload = JSON.stringify({ wire: wireHex });
    const results = await Promise.all(
      this.opts.peerUrls.map(async (peer) => {
        const url = `${peer}/v3/precommit`;
        try {
          const ctrl = new AbortController();
          const t = setTimeout(() => ctrl.abort(), this.opts.timeoutMs);
          try {
            const res = await fetch(url, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: payload,
              signal: ctrl.signal
            });
            if (!res.ok) return false;
            const body = await res.json().catch(() => null);
            return body?.accepted === true;
          } finally {
            clearTimeout(t);
          }
        } catch {
          return false;
        }
      })
    );
    return results.some((ok) => ok);
  }
};
function sleep(ms) {
  return new Promise((res) => setTimeout(res, ms));
}
function toHex2(bytes) {
  let s = "0x";
  for (let i = 0; i < bytes.length; i++) s += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  return s;
}
function hexToBytes10(hex2) {
  const s = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  if (s.length % 2 !== 0) throw new Error(`odd-length hex string (${s.length} chars)`);
  const out = new Uint8Array(s.length / 2);
  for (let i = 0; i < out.length; i++) {
    const byte = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);
    out[i] = byte;
  }
  return out;
}

// src/node/node.ts
init_v3();

// src/v3/committee-schedule.ts
function nextReconfigureEpoch(appliedHeight, lastScheduledEpoch, epochBlocks) {
  if (epochBlocks <= 0n) return null;
  const nextEpoch = (appliedHeight + 1n) / epochBlocks;
  if (nextEpoch < 1n) return null;
  if (nextEpoch <= lastScheduledEpoch) return null;
  return { epoch: nextEpoch, fromHeight: nextEpoch * epochBlocks };
}

// src/v3/precommit-relay.ts
function isPrecommitRelayEnabled(env = process.env) {
  return env.ASENTUM_PRECOMMIT_RELAY === "1";
}
function shouldRelayPrecommit(opts) {
  return opts.relayEnabled && opts.transport === "http" && opts.storeAccepted;
}

// src/node/node.ts
init_finality_journal();
init_aggregator();
import { sign as mlDsaSign, verify as mlDsaVerify } from "@asentum/crypto";

// src/node/dev-validators.ts
import {
  addressBytesFromPublicKey as addressBytesFromPublicKey5,
  generateKeypair
} from "@asentum/crypto";
var DEV_VALIDATOR_COUNT = (() => {
  const raw = process.env.ASENTUM_DEV_VALIDATOR_COUNT;
  if (!raw) return 4;
  const n = Number(raw);
  if (!Number.isInteger(n) || n < 1 || n > 200) {
    throw new Error(`ASENTUM_DEV_VALIDATOR_COUNT must be int in [1,200], got ${raw}`);
  }
  return n;
})();
function devSeed(index) {
  if (index < 0 || index >= DEV_VALIDATOR_COUNT) {
    throw new Error(`devSeed: index must be in [0, ${DEV_VALIDATOR_COUNT})`);
  }
  const seed = new Uint8Array(32);
  seed[0] = 118;
  seed[1] = index & 255;
  seed[2] = index >> 8 & 255;
  for (let i = 3; i < 32; i++) {
    seed[i] = i * 31 + index * 17 & 255;
  }
  return seed;
}
var DEV_VALIDATOR_STAKE = 1000n * 10n ** 18n;
function parseStakeEnv(name, defaultAse) {
  const raw = process.env[name];
  if (!raw) return defaultAse * 10n ** 18n;
  try {
    return BigInt(raw);
  } catch {
    throw new Error(`${name} must be a bigint wei value, got ${raw}`);
  }
}
var DEV_LIVE_COUNT_RAW = process.env.ASENTUM_DEV_LIVE_COUNT;
var DEV_LIVE_COUNT = DEV_LIVE_COUNT_RAW ? (() => {
  const n = Number(DEV_LIVE_COUNT_RAW);
  if (!Number.isInteger(n) || n < 1 || n > DEV_VALIDATOR_COUNT) {
    throw new Error(`ASENTUM_DEV_LIVE_COUNT must be int in [1, ${DEV_VALIDATOR_COUNT}], got ${DEV_LIVE_COUNT_RAW}`);
  }
  return n;
})() : null;
var DEV_LIVE_STAKE = parseStakeEnv("ASENTUM_DEV_LIVE_STAKE", 25000000n);
var DEV_GHOST_STAKE = parseStakeEnv("ASENTUM_DEV_GHOST_STAKE", 50000n);
function devValidatorStakeFor(index) {
  if (DEV_LIVE_COUNT === null) return DEV_VALIDATOR_STAKE;
  return index < DEV_LIVE_COUNT ? DEV_LIVE_STAKE : DEV_GHOST_STAKE;
}
var DEV_RPC_BASE_PORT = 18545;
function devBroadcastUrlFor(index) {
  const override = process.env.ASENTUM_DEV_BROADCAST_URLS;
  if (override) {
    const list = override.split(",").map((s) => s.trim()).filter(Boolean);
    if (list[index]) return list[index];
  }
  return `http://127.0.0.1:${DEV_RPC_BASE_PORT + index}`;
}
function getDevValidator(index) {
  const keypair = generateKeypair(devSeed(index));
  return {
    index,
    keypair,
    address: addressBytesFromPublicKey5(keypair.publicKey)
  };
}
function getDevValidatorSet() {
  const out = [];
  for (let i = 0; i < DEV_VALIDATOR_COUNT; i++) {
    out.push(getDevValidator(i));
  }
  return out;
}

// src/node/genesis.ts
import { addressBytesFromPublicKey as addressBytesFromPublicKey6, generateKeypair as generateKeypair2 } from "@asentum/crypto";
var FAUCET_SEED = new Uint8Array(32);
FAUCET_SEED[0] = 250;
FAUCET_SEED[1] = 206;
FAUCET_SEED[2] = 0;
var FAUCET_INITIAL_BALANCE = 100000000n * 10n ** 18n;
function getFaucet() {
  const keypair = generateKeypair2(FAUCET_SEED);
  return {
    keypair,
    address: addressBytesFromPublicKey6(keypair.publicKey)
  };
}

// src/node/journaled-state-store.ts
import {
  computeStateRoot
} from "@asentum/state";
import { AccountSchema } from "@asentum/types";
var ACCOUNT_PREFIX = new TextEncoder().encode("a:");
var JournaledStateStore = class {
  constructor(underlying) {
    this.underlying = underlying;
  }
  underlying;
  /**
   * Buffered account writes. Key = hex address (no prefix). Value =
   * { value: Account | null }. `null` value indicates a delete (only
   * matters if the account previously existed on disk).
   */
  accountBuf = /* @__PURE__ */ new Map();
  /**
   * Buffered code writes. Key = hex codeHash. Code is content-addressed
   * and treated as immutable — no delete support (matches underlying).
   */
  codeBuf = /* @__PURE__ */ new Map();
  /**
   * Buffered storage writes. Key = `<contractHex>:<storageKey>`. `null`
   * value = delete.
   */
  storageBuf = /* @__PURE__ */ new Map();
  // ─── Read-side: check buffer first, fall through to underlying ────────
  async getAccount(address) {
    const tag = hexOf(address);
    const buf = this.accountBuf.get(tag);
    if (buf !== void 0) return buf.value;
    return this.underlying.getAccount(address);
  }
  async getOrCreate(address) {
    const existing = await this.getAccount(address);
    return existing ?? emptyAccount2();
  }
  async getCode(codeHash) {
    const tag = hexOf(codeHash);
    const buf = this.codeBuf.get(tag);
    if (buf !== void 0) return buf.value;
    return this.underlying.getCode(codeHash);
  }
  async getStorageValue(contractAddr, key6) {
    const tag = hexOf(contractAddr) + ":" + key6;
    const buf = this.storageBuf.get(tag);
    if (buf !== void 0) return buf.value;
    return this.underlying.getStorageValue(contractAddr, key6);
  }
  async allStorageForContract(contractAddr) {
    const prefix = hexOf(contractAddr) + ":";
    const underlyingEntries = await this.underlying.allStorageForContract(contractAddr);
    const merged = /* @__PURE__ */ new Map();
    for (const e of underlyingEntries) merged.set(e.key, e.value);
    for (const [tag, buf] of this.storageBuf) {
      if (!tag.startsWith(prefix)) continue;
      const key6 = tag.slice(prefix.length);
      if (buf.value === null) merged.delete(key6);
      else merged.set(key6, buf.value);
    }
    return Array.from(merged.entries()).map(([key6, value]) => ({ key: key6, value }));
  }
  async computeContractStorageRoot(contractAddr) {
    const entries = await this.allStorageForContract(contractAddr);
    const encoded = entries.map((e) => ({
      key: new TextEncoder().encode(e.key),
      value: new TextEncoder().encode(e.value)
    }));
    return computeStateRoot(encoded);
  }
  /**
   * State root over the OVERLAY view: underlying accounts merged with
   * buffered writes. This is the crash-safe semantics — caller computes
   * the would-be post-commit root WITHOUT actually committing anything.
   */
  async stateRoot() {
    const underlyingAccts = await this.underlying.allAccounts();
    const merged = /* @__PURE__ */ new Map();
    for (const { address, account } of underlyingAccts) {
      merged.set(hexOf(address), account);
    }
    for (const [tag, buf] of this.accountBuf) {
      merged.set(tag, buf.value);
    }
    const entries = [];
    for (const [tag, account] of merged) {
      if (account === null) continue;
      const address = bytesOfHex(tag);
      const value = AccountSchema.serialize(account);
      entries.push({ key: address, value });
    }
    return computeStateRoot(entries);
  }
  async allAccounts() {
    const underlyingAccts = await this.underlying.allAccounts();
    const merged = /* @__PURE__ */ new Map();
    for (const { address, account } of underlyingAccts) {
      merged.set(hexOf(address), account);
    }
    for (const [tag, buf] of this.accountBuf) {
      merged.set(tag, buf.value);
    }
    const out = [];
    for (const [tag, account] of merged) {
      if (account === null) continue;
      out.push({ address: bytesOfHex(tag), account });
    }
    return out;
  }
  async accountCount() {
    const accounts = await this.allAccounts();
    return accounts.length;
  }
  // ─── Write-side: stage in buffer only, no underlying writes ───────────
  async setAccount(address, account) {
    this.accountBuf.set(hexOf(address), { value: account });
  }
  async deleteAccount(address) {
    this.accountBuf.set(hexOf(address), { value: null });
  }
  async putCode(codeHash, source) {
    this.codeBuf.set(hexOf(codeHash), { value: source });
  }
  async setStorageValue(contractAddr, key6, value) {
    const tag = hexOf(contractAddr) + ":" + key6;
    this.storageBuf.set(tag, { value });
  }
  async deleteStorageValue(contractAddr, key6) {
    const tag = hexOf(contractAddr) + ":" + key6;
    this.storageBuf.set(tag, { value: null });
  }
  // ─── Commit / rollback ────────────────────────────────────────────────
  /**
   * Atomically flush all buffered writes to the underlying store, then
   * clear the buffer. Currently this iterates entries and writes one
   * at a time — the underlying classic-level supports batching but the
   * wrapper StateStore API doesn't expose it yet. The key crash-safety
   * property still holds: nothing reaches disk before this call.
   *
   * If the underlying store throws mid-flush, the buffer is NOT cleared
   * — caller can retry or rollback. Writes already flushed before the
   * throw remain on disk (partial commit), which is technically not
   * atomic on a per-block basis but is safe at the LevelDB single-key
   * level. v2 should add a batch primitive to the StateStore API.
   */
  async commit() {
    for (const [tag, buf] of this.accountBuf) {
      const address = bytesOfHex(tag);
      if (buf.value === null) {
        await this.underlying.deleteAccount(address);
      } else {
        await this.underlying.setAccount(address, buf.value);
      }
    }
    for (const [tag, buf] of this.codeBuf) {
      const codeHash = bytesOfHex(tag);
      await this.underlying.putCode(codeHash, buf.value);
    }
    for (const [tag, buf] of this.storageBuf) {
      const sep = tag.indexOf(":");
      const contractAddr = bytesOfHex(tag.slice(0, sep));
      const key6 = tag.slice(sep + 1);
      if (buf.value === null) {
        await this.underlying.deleteStorageValue(contractAddr, key6);
      } else {
        await this.underlying.setStorageValue(contractAddr, key6, buf.value);
      }
    }
    this.accountBuf.clear();
    this.codeBuf.clear();
    this.storageBuf.clear();
  }
  /**
   * Discard all buffered writes without touching the underlying store.
   * Crash-safe rollback: even if this method itself crashes, the
   * underlying store was never modified.
   */
  async rollback() {
    this.accountBuf.clear();
    this.codeBuf.clear();
    this.storageBuf.clear();
  }
  // ─── Overlay snapshot / seed (pending-state memoization) ──────────────
  /**
   * Detached copy of the current buffered writes. Account objects are
   * cloned (subsidy/cron paths mutate `.balance` in place on the object
   * getAccount returned, which is the buffered object by reference — a
   * shared snapshot would silently drift). Code bytes and storage strings
   * are treated as immutable everywhere, so they are shared, not copied.
   */
  snapshotOverlay() {
    const accounts = /* @__PURE__ */ new Map();
    for (const [tag, buf] of this.accountBuf) {
      accounts.set(tag, cloneAccount(buf.value));
    }
    const code = /* @__PURE__ */ new Map();
    for (const [tag, buf] of this.codeBuf) code.set(tag, buf.value);
    const storage = /* @__PURE__ */ new Map();
    for (const [tag, buf] of this.storageBuf) storage.set(tag, buf.value);
    return { accounts, code, storage };
  }
  /**
   * Load a snapshot into this (empty, fresh) journal, cloning accounts so
   * later in-place mutations on this journal never reach the snapshot.
   * Result is byte-identical to having replayed whatever produced the
   * snapshot: reads hit the seeded buffer first, everything else falls
   * through to the underlying store exactly as before.
   */
  seedOverlay(overlay) {
    if (this.journalSize() > 0) {
      throw new Error("seedOverlay: journal already has buffered writes");
    }
    for (const [tag, value] of overlay.accounts) {
      this.accountBuf.set(tag, { value: cloneAccount(value) });
    }
    for (const [tag, value] of overlay.code) this.codeBuf.set(tag, { value });
    for (const [tag, value] of overlay.storage) this.storageBuf.set(tag, { value });
  }
  /** Diagnostics — buffered write count across all three kinds. */
  journalSize() {
    return this.accountBuf.size + this.codeBuf.size + this.storageBuf.size;
  }
  /** Used by tests to peek at uncommitted state. */
  hasBufferedWrites() {
    return this.journalSize() > 0;
  }
};
function hexOf(b) {
  let s = "";
  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
  return s;
}
function bytesOfHex(hex2) {
  const out = new Uint8Array(hex2.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(hex2.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}
function cloneAccount(a) {
  if (a === null) return null;
  return {
    balance: a.balance,
    nonce: a.nonce,
    codeHash: new Uint8Array(a.codeHash),
    storageRoot: new Uint8Array(a.storageRoot)
  };
}
function emptyAccount2() {
  return {
    balance: 0n,
    nonce: 0n,
    codeHash: new Uint8Array(32),
    storageRoot: new Uint8Array(32)
  };
}

// src/node/mempool.ts
import { blake3 as blake317 } from "@asentum/crypto";
import { SignedTransactionSchema as SignedTransactionSchema4 } from "@asentum/types";
import { addressFromPublicKey } from "@asentum/crypto";
function txHash2(tx) {
  const bytes = SignedTransactionSchema4.serialize(tx);
  const hash = blake317(bytes);
  let hex2 = "0x";
  for (let i = 0; i < hash.length; i++) hex2 += hash[i].toString(16).padStart(2, "0");
  return hex2;
}
function senderAddress(tx) {
  return addressFromPublicKey(tx.publicKey).toLowerCase();
}
var Mempool = class {
  txs = [];
  seen = /* @__PURE__ */ new Set();
  /** Maximum pool size. Txs beyond this are rejected. */
  maxSize;
  constructor(maxSize = 1e4) {
    this.maxSize = maxSize;
  }
  /**
   * Submit a transaction. Returns true if accepted, false if duplicate
   * or pool is full.
   */
  submit(tx) {
    if (this.txs.length >= this.maxSize) return false;
    const hash = txHash2(tx);
    if (this.seen.has(hash)) return false;
    this.seen.add(hash);
    this.txs.push(tx);
    return true;
  }
  /** Remove up to `max` transactions from the front of the pool and return them. */
  drain(max) {
    const drained = this.txs.splice(0, Math.min(max, this.txs.length));
    return drained;
  }
  /** Snapshot of all pending transactions (non-destructive). */
  all() {
    return this.txs;
  }
  size() {
    return this.txs.length;
  }
  /** Number of pending txs from a given sender address (lowercase hex
   * with 0x prefix). Used by callers that maintain an in-memory nonce
   * cache and need to reconcile it against chain + mempool state. */
  sizeForSender(senderHexLower) {
    let n = 0;
    for (const tx of this.txs) {
      if (senderAddress(tx) === senderHexLower) n += 1;
    }
    return n;
  }
  clear() {
    this.txs.length = 0;
    this.seen.clear();
  }
  /**
   * Evict transactions whose nonce is below the sender's current on-chain
   * nonce. Call this after applying a block to clean up stale txs.
   *
   * @param getNonce — async function that returns the current nonce for an address
   */
  async evictStale(getNonce) {
    const nonceCache = /* @__PURE__ */ new Map();
    const getOrFetch = async (addr) => {
      let n = nonceCache.get(addr);
      if (n === void 0) {
        n = await getNonce(addr);
        nonceCache.set(addr, n);
      }
      return n;
    };
    let evicted = 0;
    let i = 0;
    while (i < this.txs.length) {
      const tx = this.txs[i];
      const addr = senderAddress(tx);
      const currentNonce = await getOrFetch(addr);
      if (tx.body.nonce < currentNonce) {
        this.txs.splice(i, 1);
        evicted++;
      } else {
        i++;
      }
    }
    return evicted;
  }
};

// src/node/validator-key.ts
import { randomBytes } from "crypto";
import { mkdir as mkdir4, readFile as readFile5, stat, writeFile as writeFile5 } from "fs/promises";
import { dirname as dirname3 } from "path";
import {
  addressBytesFromPublicKey as addressBytesFromPublicKey7,
  generateKeypair as generateKeypair3
} from "@asentum/crypto";
var DILITHIUM3_SEED_BYTES = 32;
async function loadOrCreateValidatorKey(opts) {
  let exists = false;
  try {
    await stat(opts.path);
    exists = true;
  } catch {
    exists = false;
  }
  if (exists) {
    const seed2 = new Uint8Array(await readFile5(opts.path));
    if (seed2.length !== DILITHIUM3_SEED_BYTES) {
      throw new Error(
        `validator.key: expected ${DILITHIUM3_SEED_BYTES}-byte Dilithium3 seed, got ${seed2.length} bytes at ${opts.path}`
      );
    }
    const keypair2 = generateKeypair3(seed2);
    return {
      keypair: keypair2,
      address: addressBytesFromPublicKey7(keypair2.publicKey),
      source: "file"
    };
  }
  if (!opts.generateIfMissing) {
    return null;
  }
  const seed = new Uint8Array(randomBytes(DILITHIUM3_SEED_BYTES));
  await mkdir4(dirname3(opts.path), { recursive: true });
  await writeFile5(opts.path, seed, { mode: 384 });
  const keypair = generateKeypair3(seed);
  return {
    keypair,
    address: addressBytesFromPublicKey7(keypair.publicKey),
    source: "generated"
  };
}
function validatorKeyFromKeypair(keypair, source = "faucet-fallback") {
  return {
    keypair,
    address: addressBytesFromPublicKey7(keypair.publicKey),
    source
  };
}

// src/node/node.ts
var DEFAULT_BLOCK_TIME_MS = 2e3;
var MAX_TXS_PER_BLOCK = 1e3;
var BLOCK_GAS_LIMIT = 30000000n;
var BASE_FEE_PER_GAS = 1n;
var TRANSFER_GAS_LIMIT = 21000n;
var EPOCH_BLOCKS = BigInt(process.env.ASENTUM_EPOCH_BLOCKS ?? "1440");
var V3_LEADER_FILTER_FROM_HEIGHT = process.env.ASENTUM_V3_LEADER_FILTER_FROM_HEIGHT != null ? BigInt(process.env.ASENTUM_V3_LEADER_FILTER_FROM_HEIGHT) : null;
var V3_LEADER_FILTER_FROM_EPOCH = V3_LEADER_FILTER_FROM_HEIGHT != null ? (V3_LEADER_FILTER_FROM_HEIGHT + EPOCH_BLOCKS - 1n) / EPOCH_BLOCKS : null;
var V3_LEADER_NOGRACE_FROM_EPOCH = process.env.ASENTUM_V3_LEADER_NOGRACE_FROM_EPOCH != null ? BigInt(process.env.ASENTUM_V3_LEADER_NOGRACE_FROM_EPOCH) : null;
var V3_VOTER_FILTER_FROM_HEIGHT = process.env.ASENTUM_V3_VOTER_FILTER_FROM_HEIGHT != null ? BigInt(process.env.ASENTUM_V3_VOTER_FILTER_FROM_HEIGHT) : null;
var V3_VOTER_FILTER_FROM_EPOCH = V3_VOTER_FILTER_FROM_HEIGHT != null ? (V3_VOTER_FILTER_FROM_HEIGHT + EPOCH_BLOCKS - 1n) / EPOCH_BLOCKS : null;
var V3_PENDING_BLOCKS = 10n;
var SeedBlockUnavailableError = class extends Error {
  constructor(epoch, seedBlockNumber) {
    super(
      `epoch ${epoch} committee unresolvable: seed block ${seedBlockNumber} not in blockStore yet`
    );
    this.epoch = epoch;
    this.seedBlockNumber = seedBlockNumber;
    this.name = "SeedBlockUnavailableError";
  }
  epoch;
  seedBlockNumber;
};
var COMMITTEE_CAP = 100;
var V3_MAX_COMMITTEE = Number(process.env.ASENTUM_V3_MAX_COMMITTEE ?? String(COMMITTEE_CAP));
var V3_COMMITTEE_CAP_FROM_HEIGHT = process.env.ASENTUM_V3_COMMITTEE_CAP_FROM_HEIGHT != null ? BigInt(process.env.ASENTUM_V3_COMMITTEE_CAP_FROM_HEIGHT) : null;
var V3_COMMITTEE_CAP_FROM_EPOCH = V3_COMMITTEE_CAP_FROM_HEIGHT != null ? (V3_COMMITTEE_CAP_FROM_HEIGHT + EPOCH_BLOCKS - 1n) / EPOCH_BLOCKS : null;
var V3_MAX_COMMITTEE_RAISED = Number(
  process.env.ASENTUM_V3_MAX_COMMITTEE_RAISED ?? String(V3_MAX_COMMITTEE)
);
var V3_MAX_COMMITTEE_RAISE_FROM_HEIGHT = process.env.ASENTUM_V3_MAX_COMMITTEE_RAISE_FROM_HEIGHT != null ? BigInt(process.env.ASENTUM_V3_MAX_COMMITTEE_RAISE_FROM_HEIGHT) : null;
var V3_MAX_COMMITTEE_RAISE_FROM_EPOCH = V3_MAX_COMMITTEE_RAISE_FROM_HEIGHT != null ? (V3_MAX_COMMITTEE_RAISE_FROM_HEIGHT + EPOCH_BLOCKS - 1n) / EPOCH_BLOCKS : null;
var EPOCH_ENTRY_WINDOW = BigInt(process.env.ASENTUM_EPOCH_ENTRY_WINDOW ?? "100");
var LIVENESS_PERSIST_INTERVAL = BigInt(process.env.ASENTUM_LIVENESS_PERSIST_INTERVAL ?? "100");
var AsentumNode = class _AsentumNode {
  stateStore;
  blockStore;
  mempool = new Mempool();
  chainId;
  blockTimeMs;
  manualBlockProduction;
  dataDir;
  role;
  vm;
  consensusEngine;
  generateValidatorKey;
  voteTimeoutMs;
  quorumGraceMs;
  proposalTimeoutMs;
  /**
   * @dev once we've stepped past round `skipThreshold` at any single
   * height the chain is in distress (slow or dead proposer being
   * cycled past) and every subsequent round at this height uses the
   * shorter `fastTimeoutMs` instead of the full proposal timeout.
   * Robust to both timeout-driven and peer-sync-driven round advances
   * because we read the round number from the consensus engine on
   * every re-arm. Local-only, no consensus implication.
   */
  skipThreshold = 3;
  fastTimeoutMs = 800;
  /** Per-round linear growth for proposal + vote timeouts. Stops
   * slightly-drifted validators from timing out at staggered absolute
   * moments and never converging. timeout = baseMs + round * increment,
   * capped. */
  roundIncrementMs = 500;
  roundTimeoutCapMs = 2e4;
  /**
   * @dev skip-dead-proposer: when the scheduled proposer for the current
   * (height, round) hasn't produced an accepted block in the last
   * `deadProposerStaleBlocks` heights, treat them as offline and arm a
   * shorter wait timer so the round rolls over fast. Triggered by a real
   * stall on 2026-05-23 at h=65789: a bonded validator with no live node
   * (0xdc0c…, 50k stake) was scheduled and the chain wedged for >1h
   * because the next round's proposer was the SAME dead validator and
   * the full 4s timeout took too long to advance through enough rounds
   * to hit a live one.
   *
   * 1.5s is conservative on purpose — yesterday's 800ms attempt caused
   * round-counter races where a fast skipper rejected legit proposals as
   * stale. 1.5s is shorter than 4s but still longer than realistic
   * broadcast RTT, so live validators get a fair chance.
   *
   * 2026-06-03: 1.0s + 250ms increment was tried during a deploy under
   * heavy dead-proposer rotation (53 bonded ghosts). The rolling restart
   * + faster round advance triggered an N=4 prevote faction split (each
   * baked locked on a different proposed block in different rounds) and
   * the chain wedged at h=82907. Reverted to 1.5s/500ms within minutes.
   * Tightening these without first shipping the locked-prevote rule
   * (#237) is unsafe at the current bonded ghost ratio.
   */
  deadProposerTimeoutMs = 1500;
  deadProposerStaleBlocks = 50n;
  /**
   * proposer-address-hex → height of the most recent block they produced
   * (as observed locally — populated on every accepted commit). Used by
   * `armProposalTimeoutIfNotProposer` to decide whether the scheduled
   * proposer counts as "alive" and gets the full 4s wait, or "stale" and
   * gets the 1.5s fast wait. Pure in-memory, no consensus implication.
   */
  lastProposedHeight = /* @__PURE__ */ new Map();
  devnet;
  validatorIndex;
  joinPeerRpcUrl;
  /** Peer URLs used by kickOffCatchup for block-sync. */
  catchupPeerUrls;
  /** Singleton catchup promise — guards against parallel loops. */
  catchupInFlight = null;
  /** Re-audit finding: log-once guard for the empty-peers warning inside kickOffCatchup. */
  emptyCatchupPeersLogged = /* @__PURE__ */ new Set();
  /** Target height the running catchup is climbing to. */
  catchupTarget = 0n;
  genesisSpec = null;
  /**
   * #416: address (lowercase 0x hex) -> ML-DSA-65 public key bytes for the
   * genesis committee, used to authenticate inbound GRANDPA votes at ingest.
   * Populated from the v3 spec's initialCommittee at init. Empty when the
   * pubkey is unknown (legacy spec) -> verifyPeerVoteV3 fails OPEN for that
   * voter so an unverifiable vote is never dropped (never worse for liveness
   * than the pre-#416 accept-everything behaviour).
   */
  v3CommitteePubkeys = /* @__PURE__ */ new Map();
  /**
   * v3 (Aura + GRANDPA) engine. null unless USE_V3_ENGINE=1 was set +
   * initV3() was called. When present, v3 drives consensus and this
   * class's v2 consensusEngine stays quiescent.
   *
   * See notes/v3-wire-in-plan.md for the hook-by-hook rationale, and
   * notes/v3-quickstart.md for how to run a devnet under the wire-in.
   */
  v3 = null;
  /**
   * #424 P2b: the highest epoch whose committee we have already scheduled into
   * the v3 engine (dynamic validator set). -1 until the first boundary. Only
   * strictly-increasing epochs are scheduled, so reconfigure never throws on a
   * re-applied or out-of-order block.
   */
  v3LastScheduledEpoch = -1n;
  /**
   * #424 P3: memoized dormancy-filtered committee per epoch. computeCommitteeForEpoch
   * recurses one epoch back to judge liveness, so memoizing bounds that walk and
   * keeps the (deterministic) result stable within a run.
   */
  v3CommitteeCache = /* @__PURE__ */ new Map();
  // #BUG1: separate cache for the DETERMINISTIC (pre-dormancy) committee used
  // by reward reconstruction. Keyed by epoch, holds the pure on-chain committee
  // with NO local-liveness filtering, so every node reconstructs the identical
  // reward set and the state root is reproducible.
  v3DetCommitteeCache = /* @__PURE__ */ new Map();
  /**
   * #424 transport: standard ANT port peers derive a committee member's dial
   * address from its broadcastUrl host when the on-chain antAddr is unset.
   */
  standardAntPort = Number(process.env.ASENTUM_ANT_PORT ?? "9000") || 9e3;
  /**
   * Deferred v3 transport callbacks. initV3() runs BEFORE ANT swarm is
   * up in bin/run.ts, so broadcast hooks close over these refs and
   * pick up the real functions once bin/run.ts calls
   * setV3BroadcastCallbacks() post-swarm-start. Null = no-op (safe).
   */
  v3SendBlock = null;
  v3SendVote = null;
  v3SendSyncRequest = null;
  /**
   * Precommit-relay telemetry (ASENTUM_PRECOMMIT_RELAY): count of
   * HTTP-ingested precommits re-emitted to ANT peers, plus a 10s log
   * throttle so a busy relay never floods the journal.
   */
  v3RelayedPrecommits = 0;
  v3LastRelayLogMs = 0;
  /**
   * Pending journals keyed by AuraBlockHeader hash. Populated on our
   * own produce (assembleBlockBody) and on peer apply
   * (applyV3PeerBlock). Committed by commitV3Finalized when GRANDPA
   * seals the block's chain. Under Aura the pending depth is bounded
   * by finality lag (~3 blocks at 3s slot / 9s finality). See
   * notes/v3-fork-aware-implementation.md.
   */
  v3PendingBlocks = /* @__PURE__ */ new Map();
  /**
   * Post-state overlay memo per pending block (keyed by the same Aura-hash
   * hex as v3PendingBlocks). rebuildV3StateAt replays cron+txs+subsidies for
   * EVERY pending block from last-finalized to target on EVERY apply/produce.
   * Steady state that is ~FINALITY_LAG blocks — cheap. But a node whose
   * finality is pinned (behind node catching up: pull-mode external, or a
   * baked that lost quorum) never prunes pending, so depth D grows without
   * bound and each apply costs O(D) full replays → O(D²) cumulative. Measured
   * ~81ms per pending block on prod; at D=16k that's ~22min per block, event
   * loop pegged, RPC dead, node can never catch up. The memo turns each
   * rebuild into "clone the parent's cached delta, replay only the suffix" —
   * a strict memoization of the SAME deterministic computation, so the
   * resulting state is byte-identical to the full replay (same code, same
   * order, same inputs; nothing is computed differently, only not
   * re-computed). Entries are pruned exactly where v3PendingBlocks is pruned.
   *
   * Memory: one overlay = the accounts touched since last-finalized (tens of
   * entries, ~200B each, on testnet). Snapshots are per-block full copies —
   * no structural sharing — so at the cap this is cacheMax × overlay size
   * (~4096 × ~20KB ≈ 80MB worst case, far below the replay-spiral cost).
   * Eviction drops the OLDEST entries; rebuilds only ever seed from the
   * newest (the direct parent), so eviction never costs a full replay.
   * The staking cache is separate because rebuildV3StakingStateAt computes a
   * DIFFERENT state (deliberately skips subsidies) — the two overlays must
   * never cross-seed. Escape hatch: ASENTUM_V3_OVERLAY_CACHE=0 restores the
   * full-replay path (also used to A/B the perf fix on devnet).
   */
  v3StateOverlayCache = /* @__PURE__ */ new Map();
  v3StakingOverlayCache = /* @__PURE__ */ new Map();
  static V3_OVERLAY_CACHE_MAX = 4096;
  /**
   * Last finalized v2 header under v3. RPC + assemble state reads
   * derive from this. Advances only in commitV3Finalized.
   */
  v3LastFinalizedHeader = null;
  /**
   * #408 Bug 3 (reward-replay off-by-one): Aura-header-hash (hex) -> committed
   * v2 header, for the most-recently finalized blocks. loadV3ParentHeader needs
   * the header of the block a given Aura parent-hash identifies. Pending parents
   * live in v3PendingBlocks; once a block finalizes it is PRUNED from pending, so
   * without this map loadV3ParentHeader fell back to v3LastFinalizedHeader — the
   * CURRENT finalized tip, which under finality-lag is NOT the requested parent.
   * That returned the wrong prevBlockMeta (rewards attributed to the tip's
   * committee/proposer instead of the true parent's), shifting exactly one
   * block's subsidy and diverging the state root -> [v3-verify] REJECT. Keyed by
   * Aura hash so it resolves the identical value the pending map used pre-prune.
   * Bounded (LRU-ish trim) to a few finality-lag depths; only recent finalized
   * parents are ever asked for.
   */
  v3FinalizedByAura = /* @__PURE__ */ new Map();
  static V3_FINALIZED_BY_AURA_MAX = 256;
  /**
   * Pull-mode (#424 v3 externals): height -> Aura-header hash of the block
   * finalized at that height. Lets GET /v3/proposal reconstruct the exact
   * `block-with-body` wire frame an ANT PROPOSAL carries — the receiver needs
   * the block's parentAuraHash (= this map at height-1) to rebuild the
   * AuraBlockHeader that fork-choice hashes on. Populated wherever
   * v3FinalizedByAura is (commit path + boot replay).
   *
   * #418: bounded MUCH wider than v3FinalizedByAura (256). A from-snapshot
   * external boots N blocks behind the live tip and its pull cursor asks
   * GET /v3/proposal?height=head+1 — a HISTORICAL height. If head+1's parent
   * aura hash has been evicted from this window, the baked returns 204 and the
   * external freezes at the snapshot height forever (never catches up, never
   * bonds). A 256 window only covered externals <256 blocks behind, which a
   * fresh snapshot never is. The wider window lets the existing cursor drain
   * the whole gap. Boot replay (reconstructV3EngineOnBoot) walks every height
   * 1..head so it repopulates this to the cap for free — no extra work. Memory
   * is ~40B/entry (bigint + 32B hash), so 50k ≈ 2MB. Override with
   * ASENTUM_V3_AURA_HASH_WINDOW.
   */
  static V3_AURA_HASH_WINDOW = Math.max(
    256,
    Number(process.env.ASENTUM_V3_AURA_HASH_WINDOW ?? "50000")
  );
  v3AuraHashByHeight = /* @__PURE__ */ new Map();
  /** v3 pull-mode client (externals only). Null on baked / when flag off. */
  v3PullService = null;
  /**
   * #408 Bug 3: serialization chain for commitV3Finalized. onFinalize is
   * fire-and-forget, so overlapping finality events would otherwise race on the
   * shared stateStore. Chaining every commit through this promise guarantees
   * strictly-sequential application against a settled base.
   */
  v3CommitChain = Promise.resolve();
  /**
   * Last GRANDPA finality cert observed. Used by
   * buildV3LastCommitBitmap to answer "who signed the previous
   * finalized block" when the subsidy hook runs. Populated by
   * commitV3Finalized. See notes/v3-rewards-bitmap-bridge-design.md.
   */
  v3LastFinalizedCert = null;
  /**
   * Aura-header hash (hex) of the block v3LastFinalizedCert certifies. Used to
   * gate #424 cert-augmentation: a re-emitted onFinalize for an already-
   * committed block must refresh the cached cert ONLY when it certifies this
   * exact block, never a same-height fork block (which would poison the next
   * block's reward bitmap and diverge state). Set alongside v3LastFinalizedCert.
   */
  v3LastFinalizedTargetHex = null;
  latestHeader;
  faucet;
  validatorKey;
  /**
   * Persistent record of every vote this validator has signed. Consulted
   * before every signing attempt — refuses to sign a conflicting vote at
   * the same (height, round, kind), which would otherwise be a slashable
   * double-sign. Survives process restarts via a JSON file in the data dir.
   */
  voteRecord;
  faucetNextNonce = 0n;
  producerInterval;
  v3DivergenceProbe;
  running = false;
  /**
   * Cached proposal block for the current height. When the proposer
   * builds a block but quorum times out (e.g. a new peer isn't reachable
   * yet), the production timer retries. Without this cache, the retry
   * builds a NEW block with a new timestamp → new hash → the engine
   * sees two prevotes for different hashes at the same (height, round)
   * from the same validator → double-sign → self-tombstone.
   *
   * With the cache, retries reuse the same block (same hash → same
   * prevote → no conflict). The cache is cleared when the height
   * advances via a successful commit or when a different validator
   * proposes and we move to a new height via replica sync.
   */
  cachedProposal = null;
  /**
   * Cached active committee for the current epoch. Recomputed only on
   * epoch boundaries — within an epoch the committee is intentionally
   * stable so a mid-epoch bond/unbond doesn't cause a vote-set change.
   * Tombstoning is the only mid-epoch event that visibly affects the
   * cached set's effective stake (slashed validators contribute zero).
   */
  cachedCommittee = null;
  /**
   * Per-epoch cache of `getEpochCandidatesFiltered` output. The staking
   * contract pins this read to epochStartBlock, so within an epoch the
   * answer is constant — but it's called from applyBlockSubsidies on
   * EVERY block (producer + verifier paths). Without this cache, each
   * block pays the full VM-call cost into the staking contract. Devnet
   * observed h=23 burning 38s here, single-handedly stalling the chain.
   */
  cachedCandidates = null;
  /**
   * Per-height block cache, keyed by 0x-prefixed lowercase blockHashHex.
   *
   * Populated when we receive proposals (own or external) at the current
   * height. Cleared on `resetConsensusForNextHeight` so it doesn't grow
   * across heights.
   *
   * Used by locked-prevote: when a validator is locked on hash X from a
   * prior round and the current round's proposer proposes Y, we re-prevote
   * X — but only if we have X locally so that if quorum forms, we can
   * actually commit. Validators without X locally fall through to voting
   * for the round's proposal; if quorum forms elsewhere on X, they
   * catch up via the existing block-sync path on the next height.
   */
  heightBlockCache = /* @__PURE__ */ new Map();
  /**
   * Metadata about the most-recently-committed block — needed to distribute
   * its rewards when the NEXT block commits. The next block's
   * `lastCommitBitmap` indexes into this committee to identify signers.
   *
   * Updated on every commit. In-memory only for v1 — on a node restart,
   * one block of rewards is forgone (the most recent prior block's). Acceptable
   * for v1; v1.1 should persist this alongside latestHeader.
   *
   * Null when no block has yet been applied since startup, OR after a
   * fresh genesis (block 0).
   */
  prevBlockMeta = null;
  /**
   * Phase B — heartbeat-based dynamic active committee.
   *
   * Vote messages serve as implicit heartbeats: a validator that appears in
   * a block's `lastCommitBitmap` is, by definition, alive and participating
   * at that height. We track the most recent height each bonded validator
   * voted at, then filter the consensus committee per height to exclude
   * validators who haven't voted in the last `livenessWindow` blocks.
   *
   * This means:
   *  - Ghost validators (bonded, never running a node) drop out of the
   *    proposer rotation → no "dead proposer" timeout tax per height.
   *  - The quorum threshold is computed against ACTIVE stake, not bonded
   *    stake. A bonded-but-silent validator's stake doesn't drag the
   *    quorum threshold up.
   *  - When the live validators are dense (intra-DC), N drops to the
   *    active count and consensus runs at target block time.
   *
   * Deterministic — every node observes the same lastCommitBitmaps in
   * the same blocks, so the computed active committee is identical
   * across the network.
   *
   * The map is populated by `updateLastVotedHeight` on every applied
   * block (own or external) and replayed from history at startup via
   * `bootstrapLastVotedHeight`.
   */
  lastVotedHeight = /* @__PURE__ */ new Map();
  /**
   * Consecutive-miss counter per validator. Incremented on every block
   * where the validator was in the committee but their bit was NOT set
   * in the on-chain bitmap. Reset to 0 whenever they sign. When the
   * counter reaches `maxConsecutiveMisses`, the validator falls out of
   * `computeActiveCommittee` regardless of their `lastVotedHeight`.
   *
   * Complements `lastVotedHeight`: that map records the FACT of recent
   * signing; this one records the RELIABILITY. Externals on pull-mode
   * can sign one late precommit that lands in a bitmap and inflate the
   * "active" set for hundreds of blocks under the old liveness-window
   * check — this counter drops them from active after
   * `maxConsecutiveMisses` heights, so the quorum-threshold denominator
   * only counts validators actually holding up their side.
   *
   * Fully deterministic — populated from the same on-chain bitmaps as
   * `lastVotedHeight`, updated in the same order on every node.
   */
  consecutiveMisses = /* @__PURE__ */ new Map();
  /** Baked-genesis addresses (lower-case) — always active, never filtered by miss counter. */
  bakedAddressSet = new Set(
    BAKED_GENESIS_VALIDATORS.map((v) => v.address.toLowerCase())
  );
  /**
   * Rich apply-time inputs captured on the most recent applyBlockSubsidies
   * call when `ASENTUM_DEBUG_APPLY_DUMP=1`. On a state-root mismatch these
   * get folded into the drift dump so we can diff exactly which
   * reward-derivation input diverged between nodes. See
   * chain/drift-probe.ts::SubsidyInputsSnapshot.
   */
  lastSubsidyInputs = null;
  /** Validators who voted within `currentHeight - livenessWindow` are active. */
  livenessWindow;
  /**
   * Number of consecutive blocks a validator can be in the committee
   * without signing before they fall out of the active set. Tuned so
   * a single missed block from a lagging pull-mode external doesn't
   * evict them, but a persistently offline validator drops fast enough
   * that the quorum-threshold denominator doesn't inflate.
   */
  maxConsecutiveMisses;
  /** Before this height, use all bonded as active (no liveness data yet). */
  warmupBlocks;
  /** Floor for active committee size. Below this, fall back to all bonded (degraded mode). */
  minActiveCommittee;
  /**
   * Per-(height, round) timer that fires if no proposal arrives in
   * time. On fire, the local node advances its round, giving the
   * next proposer in the rotation a chance to build.
   */
  proposalTimeoutTimer = null;
  /** Serializes concurrent block production calls so the timer can't race with itself. */
  blockProductionInFlight = null;
  /** Last (h,r) we logged a skip line for — dedup against the polling loop. */
  lastSkipLogKey = "";
  /** Serializes concurrent external block applications so receipts root verification is consistent. */
  externalApplyInFlight = Promise.resolve(null);
  /**
   * Cross-path mutex held for the duration of any apply (producer build OR
   * external apply). Required because JournaledStateStore writes through to
   * the underlying store immediately — only the journal tracks originals for
   * rollback. If two journals are active concurrently they trample each
   * other: validator A's writes appear as "originals" in B's journal, B's
   * subsequent writes overwrite A's, and committed state is the wrong half
   * of an interleaved sequence. Result: byte-different stateRoot from peers
   * applying the same block to the same starting state.
   *
   * Acquire via `withApplyLock(...)` — never read/write `applyMutex` directly,
   * because the helper does a synchronous read+write to atomically claim the
   * next slot before any await. Concurrent callers chain in arrival order.
   */
  applyMutex = Promise.resolve();
  /**
   * Pull-mode relay cache for proposals (see RPC GET /consensus/proposal).
   *
   * Validators behind NAT cannot receive inbound HTTP, so the normal
   * push-broadcast (proposer POSTs proposals to every peer) never reaches
   * them. The fix: NAT'd nodes poll a reachable peer for the current
   * proposal, sign it locally, and POST their vote outbound. This cache
   * is what that GET endpoint reads from.
   *
   * Populated in two places:
   *   1. When this node builds a block as the proposer (producer path).
   *   2. When this node receives a valid proposal via /consensus/proposal.
   *
   * Keyed by `${height}:${round}`. Entries are dropped when their height
   * falls more than RECENT_HEIGHTS behind the latest cached height — we
   * only need the in-flight + a small lookback for stragglers polling
   * slightly behind the leading edge.
   */
  proposalCache = /* @__PURE__ */ new Map();
  static PROPOSAL_CACHE_RECENT_HEIGHTS = 5n;
  /**
   * Add a proposal to the pull-mode cache and evict stale entries. Called
   * by both the producer (when broadcasting a freshly-built block) and the
   * RPC server (when a peer's POST'd proposal is accepted).
   */
  cacheProposal(rawBlock, height, round, proposerBytes) {
    const key6 = `${height}:${round}`;
    this.proposalCache.set(key6, {
      rawBlock,
      height,
      round,
      proposerHex: bytesToHex16(proposerBytes),
      cachedAt: Date.now()
    });
    let maxHeight = 0n;
    for (const e of this.proposalCache.values()) {
      if (e.height > maxHeight) maxHeight = e.height;
    }
    const cutoff = maxHeight > _AsentumNode.PROPOSAL_CACHE_RECENT_HEIGHTS ? maxHeight - _AsentumNode.PROPOSAL_CACHE_RECENT_HEIGHTS : 0n;
    for (const [k, e] of this.proposalCache) {
      if (e.height < cutoff) this.proposalCache.delete(k);
    }
  }
  /**
   * Look up a cached proposal for the pull-mode GET endpoint.
   *
   * If `round` is provided: returns the exact (height, round) entry or null.
   * If only `height` is provided: returns the entry with the highest cached
   * round for that height (the "current" proposal the poller should sign).
   * If neither: returns the most recently cached entry across all heights
   * (a "give me the latest" query for clients catching up).
   */
  getCachedProposal(height, round) {
    if (height !== void 0 && round !== void 0) {
      const entry = this.proposalCache.get(`${height}:${round}`);
      return entry ? { rawBlock: entry.rawBlock, height: entry.height, round: entry.round, proposerHex: entry.proposerHex } : null;
    }
    let best = null;
    for (const e of this.proposalCache.values()) {
      if (height !== void 0 && e.height !== height) continue;
      if (!best) {
        best = e;
        continue;
      }
      if (height !== void 0) {
        if (e.round > best.round) best = e;
      } else {
        if (e.cachedAt > best.cachedAt) best = e;
      }
    }
    return best ? { rawBlock: best.rawBlock, height: best.height, round: best.round, proposerHex: best.proposerHex } : null;
  }
  /**
   * Pull-mode vote relay (#264). Return the engine's currently-held signed
   * votes for `(height, round, kind)` so NAT'd operators can fetch peer
   * votes via HTTP and drive their local engine to quorum without inbound
   * connectivity. The engine only retains in-flight per-validator vote
   * records for the live (h, r) — returns null if the caller's (h, r)
   * doesn't match, which the RPC layer surfaces as `{available: false}`.
   * See packages/node/src/consensus/engine.ts:getCurrentRoundVotes.
   */
  getCurrentRoundVotes(kind, height, round) {
    return this.consensusEngine.getCurrentRoundVotes(kind, height, round);
  }
  /**
   * Run `fn` while holding the apply lock. Synchronously claims the next
   * slot (read current mutex + install successor before any await), then
   * awaits the previous holder, runs fn, and releases on completion.
   */
  async withApplyLock(fn) {
    const previous = this.applyMutex;
    let release = () => {
    };
    const newLock = new Promise((res) => {
      release = res;
    });
    this.applyMutex = newLock;
    await previous.catch(() => {
    });
    try {
      return await fn();
    } finally {
      release();
    }
  }
  blockListeners = [];
  txListeners = [];
  voteListeners = [];
  proposalListeners = [];
  suppressTxBroadcast = false;
  constructor(options) {
    this.chainId = options.chainId;
    this.blockTimeMs = options.blockTimeMs ?? DEFAULT_BLOCK_TIME_MS;
    this.role = options.role ?? "producer";
    this.manualBlockProduction = this.role === "replica" ? true : options.manualBlockProduction ?? false;
    this.dataDir = options.dataDir;
    this.generateValidatorKey = options.generateValidatorKey ?? false;
    this.voteTimeoutMs = options.voteTimeoutMs ?? 8e3;
    this.quorumGraceMs = options.quorumGraceMs ?? 0;
    this.proposalTimeoutMs = options.proposalTimeoutMs ?? 6e3;
    this.livenessWindow = BigInt(process.env.ASENTUM_LIVENESS_WINDOW ?? "100");
    this.maxConsecutiveMisses = Number(process.env.ASENTUM_MAX_MISSES ?? "50");
    this.warmupBlocks = BigInt(process.env.ASENTUM_WARMUP_BLOCKS ?? "50");
    this.minActiveCommittee = Number(process.env.ASENTUM_MIN_ACTIVE_COMMITTEE ?? "3");
    this.devnet = options.devnet ?? false;
    this.validatorIndex = options.validatorIndex ?? null;
    this.joinPeerRpcUrl = options.joinPeerRpcUrl ?? null;
    this.catchupPeerUrls = options.catchupPeerUrls ?? [];
    if (this.devnet && this.validatorIndex !== null) {
      if (this.validatorIndex < 0 || this.validatorIndex >= DEV_VALIDATOR_COUNT) {
        throw new Error(
          `validatorIndex must be in [0, ${DEV_VALIDATOR_COUNT}), got ${this.validatorIndex}`
        );
      }
    }
    this.stateStore = new StateStore({ path: join6(options.dataDir, "state") });
    this.blockStore = new BlockStore(join6(options.dataDir, "blocks"));
    this.vm = new Vm();
    this.consensusEngine = makeEngine({
      chainId: this.chainId,
      lpc: {
        selfAddress: this.validatorKey?.address ?? new Uint8Array(20),
        // TODO(LPC integration): wire this to a cached snapshot of the
        // async getActiveValidators() call. For now an empty stub keeps
        // the type happy and doesn't affect BFT-default runs since the
        // LPC branch only fires when USE_LPC_ENGINE=1.
        getCommittee: () => [],
        slotConfig: {
          // Anchor slot 0 to the unix epoch so every node in the cluster
          // computes IDENTICAL slot numbers from their wall clock. The
          // prior default (`Date.now()` at boot) caused each node to
          // start its own slot 0, so 4 nodes booted seconds apart all
          // thought they were the proposer at h=1 → competing proposals
          // → wedge in the mp-devnet rig 2026-07-13. A future revision
          // reads this from genesis-spec so it can migrate.
          slot0StartUnixMs: 0,
          slotDurationMs: 3e3,
          graceWindowMs: 500,
          clockSkewToleranceMs: 500
        },
        hooks: {
          // Kick block production on the slot boundary. Under LPC each
          // slot corresponds to exactly one height and one proposer; if
          // we are that proposer the produce path builds + broadcasts +
          // waits for finality. Non-proposer slots no-op (produceBlockInner
          // early-returns on proposer mismatch).
          onSlotStart: () => {
            if (!isLPCEngine()) return;
            if (this.v3) return;
            void this.produceBlockSafe().catch((err) => {
              console.error("[lpc/slot-start] produceBlockSafe failed:", err);
            });
          },
          onSlotEnd: () => {
          },
          onBlockFinalized: () => {
          }
        },
        // Real setTimeout under LPC — SlotManager relies on this to
        // re-tick at every slot boundary. Under BFT the LPC branch never
        // runs, so real timers are equally safe (LPCEngineAdapter is not
        // instantiated). The prior no-op was the reason mp-devnet made
        // one block per 30s: SlotManager fired onSlotStart once at boot
        // and never advanced again.
        setTimer: (fn, ms) => {
          const id = setTimeout(fn, ms);
          return () => clearTimeout(id);
        }
      }
    });
    if (isLPCEngine()) {
      console.log("[node] running with LPC engine (USE_LPC_ENGINE=1)");
    }
    const dataDirForCert = options.dataDir;
    this.consensusEngine.setPersistCertHook((committedHeight, blockHashHex, round, votes) => {
      const record = {
        version: 1,
        committedHeight: committedHeight.toString(),
        blockHashHex: blockHashHex.startsWith("0x") ? blockHashHex : "0x" + blockHashHex,
        round: round.toString(),
        votes: votes.map(encodeVote)
      };
      void persistCert(dataDirForCert, record);
    });
    this.consensusEngine.setBehindDetectedHook((observedHeight) => {
      if (observedHeight > this.latestHeader.number) {
        console.log(
          `[node] behind-detect trigger: observed h=${observedHeight}, local h=${this.latestHeader.number} \u2014 kicking catchup`
        );
        this.kickOffCatchup(observedHeight);
      }
    });
    this.consensusEngine.setRoundAdvancedHook(() => {
      if (this.running) this.armProposalTimeoutIfNotProposer();
    });
  }
  /** Register a listener that is called (fire-and-forget) each time a block is produced or applied. */
  onBlockProduced(listener) {
    this.blockListeners.push(listener);
  }
  /** Register a listener that is called (fire-and-forget) each time a transaction is accepted. */
  onTransactionSubmitted(listener) {
    this.txListeners.push(listener);
  }
  /** Register a listener that is called (fire-and-forget) each time this node emits a consensus vote. */
  onVoteProduced(listener) {
    this.voteListeners.push(listener);
  }
  /**
   * Register a listener fired when this node proposes a fresh block,
   * BEFORE the voting cycle. Used by the proposal broadcaster to push
   * the block to peers via /consensus/proposal so they can vote on it.
   */
  onProposalBroadcast(listener) {
    this.proposalListeners.push(listener);
  }
  getRole() {
    return this.role;
  }
  /**
   * Open stores, load or create the genesis block, and prepare the node
   * for `start()`. Safe to call multiple times (idempotent).
   */
  async init() {
    await mkdir5(join6(this.dataDir, "state"), { recursive: true });
    await mkdir5(join6(this.dataDir, "blocks"), { recursive: true });
    await this.stateStore.open();
    await this.blockStore.open();
    this.faucet = getFaucet();
    if (this.devnet && this.validatorIndex !== null) {
      const dev = getDevValidator(this.validatorIndex);
      this.validatorKey = validatorKeyFromKeypair(dev.keypair, "file");
    } else {
      const validatorKeyPath = join6(this.dataDir, "validator.key");
      const loaded = await loadOrCreateValidatorKey({
        path: validatorKeyPath,
        generateIfMissing: this.generateValidatorKey
      });
      this.validatorKey = loaded ?? validatorKeyFromKeypair(this.faucet.keypair);
    }
    this.voteRecord = new VoteRecord(this.dataDir);
    await this.voteRecord.load();
    const existingHeader = await this.blockStore.getLatestHeader();
    let genesisCreated = false;
    const specPath = join6(this.dataDir, "genesis-spec.json");
    if (existingHeader) {
      this.latestHeader = existingHeader;
      this.genesisSpec = await loadGenesisSpec(specPath);
    } else {
      let specForGenesis;
      let expectedGenesisHashHex = null;
      if (this.joinPeerRpcUrl) {
        const fetched = await fetchGenesisSpecFromPeer(this.joinPeerRpcUrl);
        specForGenesis = fromGenesisSpec(fetched);
        expectedGenesisHashHex = fetched.genesisHash.toLowerCase();
      } else {
        const isMainnet = process.env.ASENTUM_NETWORK === "mainnet";
        const FAUCET_BOND_STAKE = isMainnet ? 25000000n * 10n ** 18n : 1000n * 10n ** 18n;
        let initialValidators;
        const v3GenesisBalances = [];
        const v3SpecEnv = process.env.USE_V3_ENGINE === "1" ? process.env.ASENTUM_V3_SPEC : void 0;
        if (v3SpecEnv) {
          try {
            const { readFileSync: readFileSync2 } = await import("fs");
            const specRaw = JSON.parse(readFileSync2(v3SpecEnv, "utf-8"));
            if (Array.isArray(specRaw.initialCommittee) && specRaw.initialCommittee.length > 0) {
              initialValidators = specRaw.initialCommittee.map((v) => ({
                address: v.address_hex,
                // Fall back to a synthetic all-zero pubkey when spec is old-style. Staking
                // contract seeds address+stake regardless; only paths that need real
                // pubkey (v2 slashing verification) would be affected, and v3 has its
                // own slashing path (currently escape-hatched).
                pubKey: v.pubKey_hex ?? "0x" + "00".repeat(1952),
                stake: BigInt(v.stake_wei),
                broadcastUrl: v.broadcastUrl,
                antAddr: v.antAddr
              }));
              console.log(`[v3] genesis-seed: ${initialValidators.length} validators from v3 spec`);
              for (const v of specRaw.initialCommittee) {
                if (!v.pubKey_hex) continue;
                try {
                  this.v3CommitteePubkeys.set(v.address_hex.toLowerCase(), hexToBytes11(v.pubKey_hex));
                } catch {
                }
              }
              if (Array.isArray(specRaw.initialBalances)) {
                for (const b of specRaw.initialBalances) {
                  try {
                    v3GenesisBalances.push({ address: hexToBytes11(b.address_hex), balance: BigInt(b.balance_wei) });
                  } catch {
                  }
                }
              }
              if (v3GenesisBalances.length > 0) {
                console.log(`[v3] genesis-seed: ${v3GenesisBalances.length} initial balance grant(s) from v3 spec`);
              }
            }
          } catch (err) {
            console.error(`[v3] failed to load spec for genesis seed: ${err.message} \u2014 falling back to legacy path`);
          }
        }
        if (!initialValidators && this.devnet) {
          initialValidators = getDevValidatorSet().map((v) => ({
            address: bytesToHex16(v.address),
            pubKey: bytesToHex16(v.keypair.publicKey),
            stake: devValidatorStakeFor(v.index),
            broadcastUrl: devBroadcastUrlFor(v.index)
          }));
        } else if (!initialValidators) {
          const useBaked = process.env.ASENTUM_USE_BAKED_VALIDATORS === "1";
          initialValidators = useBaked ? BAKED_GENESIS_VALIDATORS.map((v) => ({
            address: v.address,
            pubKey: v.pubKey,
            // Always 25M per baked validator — matches mainnet by design,
            // and on testnet ensures the baked set retains a structural
            // supermajority that no plausible external bond can disrupt.
            stake: v.stake,
            broadcastUrl: v.broadcastUrl
          })) : [
            {
              address: bytesToHex16(this.faucet.address),
              pubKey: bytesToHex16(this.faucet.keypair.publicKey),
              stake: FAUCET_BOND_STAKE
            }
          ];
        }
        const TEAM_GRANT_ADDR = hexToBytes11("0xe60798d5da90ab06aa32a3f680bca423bb4bd74d");
        const TEAM_GRANT_BALANCE = 5000000n * 10n ** 18n;
        const BAKED_WALLET_GRANT = 25000000n * 10n ** 18n;
        const DEV_WALLET_ADDR = hexToBytes11("0x9eC9174B597A606a095bd6B94Fe53B2Ed6dB17C6");
        const DEV_WALLET_2_ADDR = hexToBytes11("0xe16cb40df50e2ddca2be53d329fc5e25e0e44df6");
        const DEV_WALLET_BALANCE = 50000000n * 10n ** 18n;
        const bakedWalletGrants = BAKED_GENESIS_VALIDATORS.map((v) => ({
          address: hexToBytes11(v.address),
          balance: BAKED_WALLET_GRANT
        }));
        const initialAccounts = [
          { address: this.faucet.address, balance: FAUCET_INITIAL_BALANCE },
          { address: TEAM_GRANT_ADDR, balance: TEAM_GRANT_BALANCE },
          // Pre-allocate the validator rewards pool. This is the 150M ASE
          // (15% of 1B supply) that funds block subsidies over the 6-year
          // emission schedule. Held at REWARDS_POOL_ADDRESS, debited per
          // block as validators earn. Once depleted (~year 6), validators
          // earn from priority fees only.
          { address: REWARDS_POOL_ADDRESS, balance: REWARDS_POOL_GENESIS_BALANCE },
          ...bakedWalletGrants,
          { address: DEV_WALLET_ADDR, balance: DEV_WALLET_BALANCE },
          { address: DEV_WALLET_2_ADDR, balance: DEV_WALLET_BALANCE },
          ...v3GenesisBalances
        ];
        const initialAseHolders = initialAccounts.map((a) => ({
          address: bytesToHex16(a.address),
          balance: a.balance.toString()
        }));
        specForGenesis = {
          chainId: this.chainId,
          gasLimit: BLOCK_GAS_LIMIT,
          baseFeePerGas: BASE_FEE_PER_GAS,
          initialAccounts,
          initialValidators,
          initialAseHolders
        };
      }
      const genesis = await createGenesis({
        store: this.stateStore,
        initialAccounts: specForGenesis.initialAccounts,
        chainId: specForGenesis.chainId,
        gasLimit: specForGenesis.gasLimit,
        baseFeePerGas: specForGenesis.baseFeePerGas,
        stakingVm: this.vm,
        initialValidators: specForGenesis.initialValidators,
        initialAseHolders: specForGenesis.initialAseHolders
      });
      const localGenesisHash = blake318(BlockHeaderSchema4.serialize(genesis.header));
      if (expectedGenesisHashHex) {
        const localHex = "0x" + bytesToHex16(localGenesisHash).slice(2).toLowerCase();
        if (localHex !== expectedGenesisHashHex) {
          throw new Error(
            `join: locally-computed genesis hash ${localHex} does not match peer's ${expectedGenesisHashHex}. the chain spec or the genesis code path has diverged from the peer's \u2014 refusing to continue.`
          );
        }
      }
      this.genesisSpec = toGenesisSpec({
        chainId: specForGenesis.chainId,
        gasLimit: specForGenesis.gasLimit,
        baseFeePerGas: specForGenesis.baseFeePerGas,
        initialAccounts: specForGenesis.initialAccounts,
        initialValidators: specForGenesis.initialValidators,
        initialAseHolders: specForGenesis.initialAseHolders,
        genesisHash: localGenesisHash,
        genesisStateRoot: genesis.header.stateRoot
      });
      await saveGenesisSpec(specPath, this.genesisSpec);
      await this.blockStore.putBlock(genesis);
      this.latestHeader = genesis.header;
      genesisCreated = true;
    }
    const faucetAccount = await this.stateStore.getAccount(this.faucet.address);
    this.faucetNextNonce = faucetAccount?.nonce ?? 0n;
    const persisted = await loadLiveness(this.dataDir);
    if (persisted) {
      let seededCount = 0;
      try {
        for (const e of persisted.entries) {
          const addr = e.address.toLowerCase();
          this.lastVotedHeight.set(addr, BigInt(e.lastVotedHeight));
          this.consecutiveMisses.set(addr, e.consecutiveMisses);
          seededCount++;
        }
        console.log(
          `[liveness] restored ${seededCount} validators from disk (atHeight=${persisted.atHeight})`
        );
        await this.bootstrapLastVotedHeight(BigInt(persisted.atHeight) + 1n);
      } catch (err) {
        console.warn(
          "[liveness] seed from persisted failed, falling back to full bootstrap:",
          err
        );
        this.lastVotedHeight.clear();
        this.consecutiveMisses.clear();
        await this.bootstrapLastVotedHeight();
      }
    } else {
      await this.bootstrapLastVotedHeight();
    }
    await this.resetConsensusForNextHeight();
    if (this.latestHeader.number > 0n) {
      this.prevBlockMeta = await this.reconstructPrevBlockMeta(this.latestHeader);
    }
    if (this.latestHeader.number > 0n) {
      try {
        const cert = await loadCert(this.dataDir);
        if (cert && BigInt(cert.committedHeight) === this.latestHeader.number) {
          this.consensusEngine.restoreCommittedPrecommits({
            committedHeight: BigInt(cert.committedHeight),
            blockHashHex: cert.blockHashHex,
            round: BigInt(cert.round),
            votes: cert.votes.map(decodeVote)
          });
        } else if (cert) {
          console.log(
            `[boot] cert-restore skipped: cert h=${cert.committedHeight} vs latest h=${this.latestHeader.number} \u2014 first proposal will use empty-cart escape hatch`
          );
        }
      } catch (err) {
        console.warn("[boot] cert-restore threw, falling back to empty cart:", err);
      }
    }
    this.consensusEngine.onDoubleSign((ev) => {
      this.handleDoubleSignEvidence(ev);
    });
    this.consensusEngine.onPolkaCommit((height, blockHashHex) => {
      void this.handlePolkaCommit(height, blockHashHex).catch((err) => {
        console.error(`[polka-commit] handler failed h=${height}:`, err);
      });
    });
    return {
      faucet: this.faucet,
      genesisCreated,
      height: this.latestHeader.number
    };
  }
  /**
   * React to a double-sign detection. Producers auto-submit a
   * slashing tx to their own mempool; replicas just log it (the
   * producer will pick up the same evidence in its own engine when
   * the conflicting vote arrives via gossip).
   */
  /**
   * Drive the post-commit work for a polka-rescue commit. Triggered by the
   * engine's onPolkaCommit listener. Without this hook the node's
   * latestHeader stays frozen while the engine thinks it committed —
   * producers re-broadcast the same height forever, the chain never
   * advances.
   *
   * Looks up the cached block by hash, runs the same apply pipeline that
   * `applyExternalBlock` uses for gossiped finalized blocks, persists,
   * advances the engine to h+1. Re-uses the apply mutex so any concurrent
   * external-apply or producer-build waits until persistence finishes.
   */
  async handlePolkaCommit(height, blockHashHex) {
    const cached = this.heightBlockCache.get(blockHashHex);
    if (!cached) {
      console.error(
        `[polka-commit] no block cached for hash ${blockHashHex.slice(0, 14)}\u2026 at h=${height} \u2014 chain-sync will need to catch up`
      );
      return;
    }
    if (cached.header.number !== height) {
      console.error(
        `[polka-commit] cache hit but block height ${cached.header.number} != ${height}`
      );
      return;
    }
    if (this.latestHeader.number >= height) {
      return;
    }
    const bytes = BlockSchema3.serialize(cached);
    await this.withApplyLock(async () => {
      if (this.latestHeader.number >= height) return;
      try {
        await this.applyExternalBlockInner(bytes, {});
        console.log(
          `[polka-commit] persisted h=${height} block=${blockHashHex.slice(0, 14)}\u2026 latestHeader is now ${this.latestHeader.number}`
        );
      } catch (err) {
        console.error(
          `[polka-commit] applyExternalBlockInner failed h=${height}: ${err instanceof Error ? err.message : String(err)}`
        );
      }
    });
  }
  handleDoubleSignEvidence(ev) {
    const offenderHex = bytesToHex16(ev.offender);
    console.warn(
      `[slashing] DOUBLE SIGN detected: validator=${offenderHex} kind=${ev.kind} height=${ev.height} round=${ev.round} hashA=${bytesToHex16(ev.blockHashA).slice(0, 18)}... hashB=${bytesToHex16(ev.blockHashB).slice(0, 18)}...`
    );
    if (this.role !== "producer") return;
    try {
      const voteAHex = bytesToHex16(SignedVoteSchema3.serialize(ev.voteA));
      const voteBHex = bytesToHex16(SignedVoteSchema3.serialize(ev.voteB));
      const tx = buildAndSignCall({
        senderKeypair: this.faucet.keypair,
        chainId: this.chainId,
        nonce: this.faucetNextNonce,
        to: SLASHING_CONTRACT_ADDRESS,
        value: 0n,
        gasLimit: 5000000n,
        method: "recordSlash",
        args: [offenderHex, voteAHex, voteBHex]
      });
      this.faucetNextNonce += 1n;
      const result = this.submitTransaction(tx);
      if (!result.accepted) {
        console.error(`[slashing] auto-submit rejected: ${result.reason}`);
      }
    } catch (err) {
      console.error("[slashing] auto-submit failed:", err);
    }
  }
  /**
   * Snapshot the active validator set and restart the consensus engine
   * at `latestHeader.number + 1`, round 0, phase propose. Called at
   * init(), after a block is produced, and after a block is applied
   * from the network.
   */
  async resetConsensusForNextHeight() {
    this.cachedProposal = null;
    this.heightBlockCache.clear();
    if (this.latestHeader.number > 0n) {
      const headHash = bytesToHex16(blake318(BlockHeaderSchema4.serialize(this.latestHeader)));
      this.consensusEngine.syncCommittedHashFromBlockStore({
        committedHeight: this.latestHeader.number,
        blockHashHex: headHash
      });
    }
    const bonded = await this.fetchActiveValidatorViews();
    const nextHeight = this.latestHeader.number + 1n;
    const active = this.computeActiveCommittee(nextHeight, bonded);
    const activeAddrs = new Set(active.map((v) => v.address.toLowerCase()));
    const inactiveBonded = bonded.filter((v) => !activeAddrs.has(v.address.toLowerCase()));
    this.consensusEngine.startHeight({
      height: nextHeight,
      validators: active,
      inactiveBonded,
      round: 0n
    });
    this.armProposalTimeoutIfNotProposer();
    if (this.running && !this.manualBlockProduction) {
      const proposer = this.consensusEngine.currentProposer();
      const selfHex = bytesToHex16(this.validatorKey.address).toLowerCase();
      if (proposer && proposer.address.toLowerCase() === selfHex) {
        setImmediate(() => {
          void this.produceBlockSafe().catch((err) => {
            console.error("[node] event-driven produce failed:", err);
          });
        });
      }
    }
  }
  /**
   * Phase B — record that a set of validators voted at a given height.
   * Called whenever we observe a block's `lastCommitBitmap` (which proves
   * those validators voted at height `block.number - 1`). Idempotent and
   * monotonic: only advances `lastVotedHeight` forward.
   */
  updateLastVotedHeight(signerAddrs, votedAtHeight) {
    for (const addr of signerAddrs) {
      const lower = addr.toLowerCase();
      const existing = this.lastVotedHeight.get(lower);
      if (existing === void 0 || existing < votedAtHeight) {
        this.lastVotedHeight.set(lower, votedAtHeight);
      }
    }
  }
  /**
   * Phase B — record signing liveness for one applied block.
   *
   * Every validator in the block's committee either signed or didn't.
   * Signers get their `consecutiveMisses` reset to zero and their
   * `lastVotedHeight` advanced. Non-signers get their miss counter
   * incremented. Validators outside the committee this height are
   * untouched — miss counter only ticks up on heights where they had
   * the chance to sign and didn't.
   *
   * Fully deterministic: same committee + same signer list on every
   * node → same updates. Committee is derived from the prev block's
   * epoch committee, signer list is decoded from the block's on-chain
   * cert. See `applyBlockSubsidies` for the call site.
   */
  recordBlockLiveness(committeeAddrs, signerAddrs, votedAtHeight) {
    const signers = new Set(signerAddrs.map((a) => a.toLowerCase()));
    for (const raw of committeeAddrs) {
      const addr = raw.toLowerCase();
      if (signers.has(addr)) {
        this.consecutiveMisses.set(addr, 0);
        const prev = this.lastVotedHeight.get(addr);
        if (prev === void 0 || prev < votedAtHeight) {
          this.lastVotedHeight.set(addr, votedAtHeight);
        }
      } else {
        this.consecutiveMisses.set(addr, (this.consecutiveMisses.get(addr) ?? 0) + 1);
      }
    }
    if (votedAtHeight > 0n && votedAtHeight % LIVENESS_PERSIST_INTERVAL === 0n) {
      const snapshot = {
        version: 1,
        atHeight: votedAtHeight.toString(),
        entries: []
      };
      for (const [addr, height] of this.lastVotedHeight.entries()) {
        snapshot.entries.push({
          address: addr,
          lastVotedHeight: height.toString(),
          consecutiveMisses: this.consecutiveMisses.get(addr) ?? 0
        });
      }
      void persistLiveness(this.dataDir, snapshot);
    }
  }
  /**
   * Phase B — filter bonded validators down to the active committee.
   *
   * Active = bonded validators who voted in any block within
   * `[currentHeight - livenessWindow, currentHeight - 1]`.
   *
   * Three exits to "all bonded":
   *  1. Before warmupBlocks: not enough history to filter (genesis grace).
   *  2. Active committee size < minActiveCommittee: degraded mode, alert.
   *  3. lastVotedHeight is empty (fresh restart, bootstrap not done yet).
   *
   * Deterministic: same currentHeight + same bonded list + same observed
   * vote history → same active committee on every node.
   */
  computeActiveCommittee(currentHeight, bonded) {
    if (currentHeight < this.warmupBlocks) {
      return [...bonded];
    }
    if (this.lastVotedHeight.size === 0) {
      return [...bonded];
    }
    const cutoff = currentHeight - this.livenessWindow;
    const active = bonded.filter((v) => {
      const addr = v.address.toLowerCase();
      if (this.bakedAddressSet.has(addr)) return true;
      const misses = this.consecutiveMisses.get(addr);
      if (misses !== void 0 && misses >= this.maxConsecutiveMisses) {
        return false;
      }
      const lastSeen = this.lastVotedHeight.get(addr);
      if (lastSeen === void 0) return true;
      return lastSeen >= cutoff;
    });
    if (active.length < this.minActiveCommittee) {
      console.warn(
        `[active-committee] DEGRADED h=${currentHeight}: active=${active.length} < minActive=${this.minActiveCommittee}, falling back to bonded=${bonded.length}`
      );
      return [...bonded];
    }
    if (active.length !== bonded.length) {
      console.log(
        `[active-committee] h=${currentHeight} active=${active.length}/${bonded.length} bonded (dormant=${bonded.length - active.length})`
      );
    }
    return active;
  }
  /**
   * Phase B — replay the most recent `livenessWindow` blocks at startup
   * to populate `lastVotedHeight`. Without this, a fresh node would
   * incorrectly classify every bonded validator as dormant on its first
   * height-reset, and the degraded-mode fallback would hide the bug
   * until enough new blocks arrived.
   */
  async bootstrapLastVotedHeight(startFrom) {
    const head = await this.blockStore.getLatestHeight();
    if (head === null || head === 0n) return;
    const defaultStart = head > this.livenessWindow ? head - this.livenessWindow : 1n;
    const start = startFrom !== void 0 && startFrom > defaultStart ? startFrom : defaultStart;
    let walked = 0;
    let lastEpoch = null;
    let committeeAddresses = [];
    for (let n = start; n <= head; n++) {
      const block = await this.blockStore.getBlockByNumber(n);
      if (!block) continue;
      if (n === 0n) continue;
      const bitmap = block.header.lastCommitBitmap;
      if (!bitmap || bitmap.length === 0) continue;
      const epoch = (n - 1n) / EPOCH_BLOCKS;
      if (lastEpoch === null || epoch !== lastEpoch) {
        try {
          const committee = await this.computeCommitteeForEpoch(epoch, { deterministic: true });
          committeeAddresses = committee.map((v) => v.address.toLowerCase());
          lastEpoch = epoch;
        } catch {
          continue;
        }
      }
      try {
        const signers = decodeLastCommitBitmap(bitmap, committeeAddresses);
        this.recordBlockLiveness(committeeAddresses, signers, n - 1n);
        walked++;
      } catch {
      }
    }
    console.log(
      `[active-committee] bootstrap: walked ${walked} blocks [${start}..${head}], tracking ${this.lastVotedHeight.size} validators, misses=${this.consecutiveMisses.size}`
    );
  }
  /**
   * Start a wall-clock timer that advances our round if the scheduled
   * proposer doesn't propose in time. No-op if this node is the
   * scheduled proposer.
   */
  armProposalTimeoutIfNotProposer() {
    this.clearProposalTimeout();
    if (this.v3) return;
    const proposer = this.consensusEngine.currentProposer();
    const selfHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    if (proposer && proposer.address.toLowerCase() === selfHex) return;
    let timeoutMs = this.proposalTimeoutMs;
    let proposerLooksDead = false;
    if (!proposer) {
      timeoutMs = this.deadProposerTimeoutMs;
      proposerLooksDead = true;
    } else {
      const propHex = proposer.address.toLowerCase();
      const lastSeen = this.lastProposedHeight.get(propHex);
      const curHeight = this.consensusEngine.getHeight();
      if (lastSeen === void 0 || curHeight - lastSeen > this.deadProposerStaleBlocks) {
        timeoutMs = this.deadProposerTimeoutMs;
        proposerLooksDead = true;
      }
    }
    const scaledTimeoutMs = proposerLooksDead ? timeoutMs : this.scaleTimeoutForRound(timeoutMs);
    this.proposalTimeoutTimer = setTimeout(() => {
      this.handleProposalTimeout();
    }, scaledTimeoutMs);
  }
  /** Returns `min(baseMs + round * roundIncrementMs, roundTimeoutCapMs)`. */
  scaleTimeoutForRound(baseMs) {
    const round = Number(this.consensusEngine.getRound());
    if (round <= 0) return baseMs;
    const scaled = baseMs + round * this.roundIncrementMs;
    return scaled > this.roundTimeoutCapMs ? this.roundTimeoutCapMs : scaled;
  }
  /**
   * Mark `proposerBytes` as alive at `height`. Called on every accepted
   * commit (own-build or external). Drives the dead-proposer detection
   * in `armProposalTimeoutIfNotProposer`.
   */
  recordProposerAlive(proposerBytes, height) {
    const hex2 = bytesToHex16(proposerBytes).toLowerCase();
    const prev = this.lastProposedHeight.get(hex2);
    if (prev === void 0 || prev < height) {
      this.lastProposedHeight.set(hex2, height);
    }
  }
  /** Cancel any pending proposal-timeout timer. */
  clearProposalTimeout() {
    if (this.proposalTimeoutTimer) {
      clearTimeout(this.proposalTimeoutTimer);
      this.proposalTimeoutTimer = null;
    }
  }
  /**
   * The scheduled proposer didn't propose in time. Advance the round
   * so the next proposer in the rotation can take over. If we're now
   * the new proposer, kick off block production immediately.
   */
  handleProposalTimeout() {
    if (this.v3) return;
    const oldRound = this.consensusEngine.getRound();
    const height = this.consensusEngine.getHeight();
    this.consensusEngine.advanceRound();
    const newRound = this.consensusEngine.getRound();
    const shouldLog = newRound <= 5n || newRound % 10n === 0n;
    if (shouldLog) {
      console.log(
        `[bft] proposal timeout at (height=${height}, round=${oldRound}) \u2192 advancing to round ${newRound}`
      );
    }
    const proposer = this.consensusEngine.currentProposer();
    const selfHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    if (proposer && proposer.address.toLowerCase() === selfHex) {
      void this.produceBlockSafe().catch((err) => {
        console.error("[bft] post-skip build failed:", err);
      });
    } else {
      this.armProposalTimeoutIfNotProposer();
    }
  }
  /**
   * Compute the active committee for a given epoch.
   *
   * Asentum committee model: stable core + rotating reserve.
   *
   *   - Stable core: top STABLE_CORE_SIZE bonded validators by stake.
   *     Sorted descending stake, ties broken by address. Always active —
   *     they're the always-on infrastructure layer.
   *   - Rotating reserve: up to RESERVE_SIZE validators randomly
   *     selected from the bonded-but-not-core pool, restricted to those
   *     who submitted a `submitEpochEntry` tx for this epoch (proves
   *     they're online and ready). Random selection uses a deterministic
   *     Fisher-Yates seeded with BLAKE3(epoch || prevEpochLastBlockHash),
   *     so every node arrives at the same committee without coordination.
   *
   * Edge cases:
   *   - When total bonded ≤ COMMITTEE_CAP: everyone bonded is active,
   *     no rotation needed. Reserve filtering is bypassed — small chains
   *     don't need the entry-tx ceremony.
   *   - When core fills the cap (≥ STABLE_CORE_SIZE bonded but < cap):
   *     reserve is empty, committee is just the core.
   *   - For epoch 0 (the very first one): seed input is the genesis
   *     block hash, since there is no "prior epoch's last block".
   *
   * Returns a flat list of ValidatorView ready for the consensus engine.
   */
  async computeCommitteeForEpoch(epoch, opts) {
    const deterministic = opts?.deterministic === true;
    const cache = deterministic ? this.v3DetCommitteeCache : this.v3CommitteeCache;
    const memo = cache.get(String(epoch));
    if (memo) return memo;
    const candidates = await this.getEpochCandidatesFiltered(epoch);
    const totalBonded = candidates.core.length + candidates.reserve.length;
    if (process.env.ASENTUM_DEBUG_COMMITTEE === "1" && !deterministic) {
      const self = this.validatorKey ? bytesToHex16(this.validatorKey.address).toLowerCase() : "";
      const inCore = candidates.core.some((v) => v.address.toLowerCase() === self);
      const inReserve = candidates.reserve.some((v) => v.address.toLowerCase() === self);
      console.log(
        `[dbg-committee] epoch=${epoch} self=${self.slice(0, 10)} inCore=${inCore} inReserve=${inReserve} core=${candidates.core.length} reserve=${candidates.reserve.length} coreAddrs=${candidates.core.map((v) => v.address.slice(0, 8)).join(",")}`
      );
    }
    let committee;
    const effectiveCap = V3_MAX_COMMITTEE_RAISE_FROM_EPOCH != null && epoch >= V3_MAX_COMMITTEE_RAISE_FROM_EPOCH ? V3_MAX_COMMITTEE_RAISED : V3_MAX_COMMITTEE;
    const capActive = V3_COMMITTEE_CAP_FROM_EPOCH != null && epoch >= V3_COMMITTEE_CAP_FROM_EPOCH;
    if (capActive && totalBonded > effectiveCap) {
      committee = [...candidates.core, ...candidates.reserve].slice(0, effectiveCap).map((v) => ({
        address: v.address.toLowerCase(),
        stake: v.stake,
        broadcastUrl: v.broadcastUrl,
        antAddr: v.antAddr
      }));
      const result2 = deterministic ? committee : await this.applyV3Dormancy(epoch, committee);
      cache.set(String(epoch), result2);
      console.log(
        `[v3-committee-cap] epoch=${epoch} capped ${totalBonded} bonded -> ${committee.length} active (max=${effectiveCap})`
      );
      return result2;
    }
    if (totalBonded <= COMMITTEE_CAP) {
      committee = [...candidates.core, ...candidates.reserve].map((v) => ({
        address: v.address.toLowerCase(),
        stake: v.stake,
        broadcastUrl: v.broadcastUrl,
        antAddr: v.antAddr
      }));
      const result2 = deterministic ? committee : await this.applyV3Dormancy(epoch, committee);
      cache.set(String(epoch), result2);
      return result2;
    }
    const seedSourceHeight = epoch === 0n ? 0n : epoch * EPOCH_BLOCKS - 1n;
    const seedSourceBlock = await this.blockStore.getBlockByNumber(seedSourceHeight);
    const seedSourceHash = seedSourceBlock ? blake318(BlockHeaderSchema4.serialize(seedSourceBlock.header)) : new Uint8Array(32);
    const seed = computeShuffleSeed(epoch, seedSourceHash);
    const shuffledReserve = deterministicShuffle(candidates.reserve, seed);
    const reserveSlots = COMMITTEE_CAP - candidates.core.length;
    const picked = shuffledReserve.slice(0, Math.max(0, reserveSlots));
    committee = [...candidates.core, ...picked].map((v) => ({
      address: v.address.toLowerCase(),
      stake: v.stake,
      broadcastUrl: v.broadcastUrl,
      antAddr: v.antAddr
    }));
    const result = deterministic ? committee : await this.applyV3Dormancy(epoch, committee);
    cache.set(String(epoch), result);
    return result;
  }
  /**
   * #424 P3 — deterministic liveness. Returns the set of committee addresses
   * (lowercase) that signed at least one block in `epoch`, read from the on-chain
   * cert bitmaps of that epoch's COMMITTED blocks. Because it reads the block
   * store (committed = final, never re-orged) and decodes against the epoch's own
   * committee, every node computes the identical set — the property the in-memory
   * consecutiveMisses map lacks, and the reason that map must not drive consensus.
   */
  async computeEpochSigners(epoch) {
    const signers = /* @__PURE__ */ new Set();
    const committeeAddrs = (await this.computeCommitteeForEpoch(epoch, { deterministic: true })).map((v) => v.address.toLowerCase());
    if (committeeAddrs.length === 0) return signers;
    const start = epoch * EPOCH_BLOCKS + 1n;
    const end = (epoch + 1n) * EPOCH_BLOCKS;
    for (let n = start; n <= end; n++) {
      const block = await this.blockStore.getBlockByNumber(n);
      if (!block) continue;
      const bitmap = block.header.lastCommitBitmap;
      if (!bitmap || bitmap.length === 0) continue;
      try {
        for (const a of decodeLastCommitBitmap(bitmap, committeeAddrs)) {
          signers.add(a.toLowerCase());
        }
      } catch {
      }
    }
    return signers;
  }
  /**
   * #424 P3 — dormancy filter for the dynamic validator set. Drops committee
   * members that were in the committee TWO epochs ago but signed zero blocks
   * there, so a bonded validator that goes offline stops inflating the finality
   * quorum denominator (which would otherwise wedge the chain). Two epochs back,
   * not one, because epoch E-1 is not yet fully committed when epoch E's
   * committee is first computed (finality lags production), whereas E-2 is final
   * and stable — every node sees the identical signer history, so the filtered
   * committee is deterministic and safe to drive consensus + rewards.
   *
   * Baked genesis validators are never dropped (economic-security floor), a
   * newly-joined member is graced (it had no chance to sign yet), and the set is
   * never shrunk below a safe minimum (degraded mode keeps the chain alive). A
   * dropped validator re-enters automatically once it signs again. No-op unless
   * ASENTUM_V3_DYNAMIC_COMMITTEE=1, so prod behavior is unchanged until enabled.
   */
  /**
   * Addresses that PRODUCED at least one block in `epoch` — read straight off
   * each block header's proposer (a deterministic on-chain fact). This is the
   * liveness signal for the Aura LEADER committee, distinct from the SIGNER
   * signal used by the voting committee: a pull-mode external SIGNS precommits
   * (and earns) but never PRODUCES a block, so signer-based liveness wrongly
   * keeps it in the leader rotation, where its slots dead-slot every cycle
   * (#455). Proposer-based liveness drops it from leadership while the signer
   * path keeps it voting+earning. Same block range as computeEpochSigners so
   * the two judgments cover an identical set of blocks.
   */
  async computeEpochProposers(epoch) {
    const proposers = /* @__PURE__ */ new Set();
    const start = epoch * EPOCH_BLOCKS + 1n;
    const end = (epoch + 1n) * EPOCH_BLOCKS;
    for (let n = start; n <= end; n++) {
      const block = await this.blockStore.getBlockByNumber(n);
      if (!block) continue;
      const p = block.header.proposer;
      if (p && p.length > 0) proposers.add(bytesToHex16(p).toLowerCase());
    }
    return proposers;
  }
  async applyV3Dormancy(epoch, committee, opts) {
    if (process.env.ASENTUM_V3_DYNAMIC_COMMITTEE !== "1") return committee;
    if (epoch < 3n) return committee;
    const judgeEpoch = epoch - 2n;
    let priorCommittee;
    let priorSigners;
    let freshlyEntered;
    let priorLive;
    try {
      priorCommittee = new Set(
        (await this.computeCommitteeForEpoch(judgeEpoch, { deterministic: true })).map(
          (v) => v.address.toLowerCase()
        )
      );
      priorSigners = await this.computeEpochSigners(judgeEpoch);
      priorLive = opts?.byProposer ? await this.computeEpochProposers(judgeEpoch) : priorSigners;
      freshlyEntered = await this.computeFreshEpochEntrants(epoch, judgeEpoch, {
        bondOnly: opts?.byProposer === true
      });
    } catch {
      return committee;
    }
    const selfAddr = this.validatorKey ? bytesToHex16(this.validatorKey.address).toLowerCase() : "";
    const leaderNoGrace = opts?.byProposer === true && V3_LEADER_NOGRACE_FROM_EPOCH != null && epoch >= V3_LEADER_NOGRACE_FROM_EPOCH;
    const kept = committee.filter((v) => {
      const a = v.address.toLowerCase();
      if (opts?.selfKeep !== false && selfAddr && a === selfAddr) return true;
      if (this.bakedAddressSet.has(a)) return true;
      if (!leaderNoGrace) {
        if (!priorCommittee.has(a)) return true;
        if (freshlyEntered.has(a)) return true;
      }
      return priorLive.has(a);
    });
    const floor = leaderNoGrace ? 1 : Math.max(this.minActiveCommittee ?? 1, Math.ceil(committee.length / 2));
    if (process.env.ASENTUM_V3_DORMANCY_DEBUG === "1") {
      console.log(
        `[v3-dormancy-dbg] epoch=${epoch} byProposer=${opts?.byProposer === true} committee=${committee.length} kept=${kept.length} floor=${floor} priorLive=${priorLive.size} priorSigners=${priorSigners.size} fresh=${freshlyEntered.size} priorCommittee=${priorCommittee.size}`
      );
    }
    if (kept.length < floor) {
      if (process.env.ASENTUM_V3_DORMANCY_DEBUG === "1") {
        console.log(`[v3-dormancy-dbg] epoch=${epoch} FLOOR HIT (kept ${kept.length} < floor ${floor}) \u2192 full committee`);
      }
      return committee;
    }
    if (kept.length !== committee.length) {
      console.log(
        `[v3-dormancy] epoch=${epoch} judged=${judgeEpoch} dropped ${committee.length - kept.length} dormant, active=${kept.length}/${committee.length}`
      );
    }
    return kept;
  }
  /**
   * First-chance grace for the dormancy filter. Returns the committee addresses
   * (lowercase) for the incoming epoch E that should NOT be judged against E-2's
   * signer history because they could not have had a fair full E-2 to prove
   * themselves, i.e. they bonded on or after the START of the judge epoch E-2.
   *
   * Without this, an external that bonds and enters the committee near an epoch
   * boundary (the common case: it takes a block or two for the ANT mesh to admit
   * + dial it, so it misses the early blocks of its first judge epoch) is dropped
   * by dormancy the moment it is first eligible, and only re-enters via the
   * new-joiner grace two epochs later, a permanent in-2-out-2 flap that keeps it
   * from ever accruing a clean signing epoch. Bonding is a deterministic on-chain
   * signal (bondedAt is committed state), so every node computes the identical
   * grace set. Long-bonded validators that have gone dark are NOT graced and are
   * still dropped, preserving the anti-inflation property.
   *
   * The judge epoch's start block is judgeEpoch*EPOCH_BLOCKS (0 for epoch 0).
   * A validator with bondedAt >= that start could not have signed the whole
   * judge epoch, so grace it for exactly this incoming epoch.
   */
  async computeFreshEpochEntrants(epoch, judgeEpoch, opts) {
    const fresh = /* @__PURE__ */ new Set();
    const judgeStart = judgeEpoch * EPOCH_BLOCKS;
    let candidates;
    try {
      candidates = await this.getEpochCandidatesFiltered(epoch);
    } catch {
      return fresh;
    }
    for (const v of [...candidates.core, ...candidates.reserve]) {
      const addr = v.address.toLowerCase();
      if (!opts?.bondOnly) {
        let enteredAt = 0n;
        try {
          enteredAt = BigInt((v.enteredAt ?? "0").trim() || "0");
        } catch {
          enteredAt = 0n;
        }
        if (enteredAt !== 0n && enteredAt >= judgeStart) {
          fresh.add(addr);
          continue;
        }
      }
      let bondedAt;
      try {
        bondedAt = BigInt((v.bondedAt ?? "0").trim() || "0");
      } catch {
        continue;
      }
      if (bondedAt === 0n) continue;
      if (bondedAt >= judgeStart) fresh.add(addr);
    }
    return fresh;
  }
  /**
   * #424 transport (#427). The ANT dial targets + inbound-allowlist hosts for
   * the CURRENT committee, so bin/run.ts can admit + dial committee members from
   * chain state instead of a static peer list. Self is excluded. Each member's
   * dial address is its on-chain antAddr, or (when unset) derived from the
   * broadcastUrl host + the standard ANT port. Empty when the dynamic-committee
   * flag is off, so prod transport is unchanged until enabled.
   */
  async getV3CommitteeAntTargets() {
    const hosts = /* @__PURE__ */ new Set();
    const targets = [];
    if (process.env.ASENTUM_V3_DYNAMIC_COMMITTEE !== "1") return { hosts, targets };
    const self = this.validatorKey ? bytesToHex16(this.validatorKey.address).toLowerCase() : "";
    const epoch = this.latestHeader.number / EPOCH_BLOCKS;
    let committee;
    try {
      committee = await this.computeCommitteeForEpoch(epoch, { deterministic: true });
    } catch {
      return { hosts, targets };
    }
    for (const v of committee) {
      const addr = v.address.toLowerCase();
      if (addr === self) continue;
      const resolved = this.resolveAntAddr(v);
      if (!resolved) continue;
      hosts.add(resolved.host);
      targets.push({ host: resolved.host, port: resolved.port, label: `committee:${addr.slice(0, 10)}` });
    }
    return { hosts, targets };
  }
  /**
   * Resolve a validator's ANT dial address: prefer the on-chain antAddr
   * (host:port), else derive host from broadcastUrl + the standard ANT port.
   * Null when neither is usable.
   */
  resolveAntAddr(v) {
    if (v.antAddr) {
      const i = v.antAddr.lastIndexOf(":");
      if (i > 0) {
        const host = v.antAddr.slice(0, i);
        const port = Number(v.antAddr.slice(i + 1));
        if (host && Number.isInteger(port) && port > 0) return { host, port };
      }
    }
    if (v.broadcastUrl) {
      const m = /^https?:\/\/([^:/]+)/.exec(v.broadcastUrl);
      if (m && m[1]) return { host: m[1], port: this.standardAntPort };
    }
    return null;
  }
  /**
   * #424 P2b: dynamic validator set. Schedule the v3 consensus committee
   * (Aura leaders + GRANDPA voters) for the NEXT epoch as soon as the block
   * that seeds it is applied, so a validator that bonds in at an epoch boundary
   * starts producing + finalizing + earning instead of being a spectator.
   *
   * The committee for epoch E is `computeCommitteeForEpoch(E)`, the SAME set
   * the reward path decodes the cert bitmap against (same order, converted with
   * the same `hexToBytes`), so consensus and rewards agree bit-for-bit. No epoch
   * delay is allowed: block H's leader/voters and its reward bitmap must use the
   * identical committee. Epoch E is seeded at height `E*EPOCH_BLOCKS-1`, so it is
   * computable the instant that block is applied, ahead of finality, which is
   * why this runs on the APPLY path (produce + peer), not on commit/finalize.
   *
   * Called with the height just applied. We ensure the committee for the epoch
   * of the NEXT height (`applied+1`) is scheduled, so the producer always has
   * the set for the block it may build next. Idempotent + monotonic: only
   * strictly-increasing epochs are scheduled, so re-applied or out-of-order
   * blocks never double-schedule or throw.
   *
   * GATED behind ASENTUM_V3_DYNAMIC_COMMITTEE=1. With the flag OFF the v3
   * committee stays genesis-static (current prod behavior). The flag is enabled
   * only on the devnet rig until the add-a-node proof (P5) + dormancy-in-quorum
   * (P3) land, so a real external bonding in cannot grow the finality quorum
   * and wedge prod before it is proven. See notes/v3-external-voting-design.md.
   */
  async maybeScheduleV3Reconfigure(appliedHeight) {
    if (process.env.ASENTUM_V3_DYNAMIC_COMMITTEE !== "1") return;
    if (!this.v3) return;
    try {
      const due = nextReconfigureEpoch(appliedHeight, this.v3LastScheduledEpoch, EPOCH_BLOCKS);
      if (!due) return;
      const views = await this.computeCommitteeForEpoch(due.epoch, { deterministic: true });
      const filterLeaders = V3_LEADER_FILTER_FROM_EPOCH != null && due.epoch >= V3_LEADER_FILTER_FROM_EPOCH;
      const leaderViews = filterLeaders ? await this.applyV3Dormancy(due.epoch, views, {
        selfKeep: false,
        // #455: judge leader liveness by PROPOSING (default). Set
        // ASENTUM_V3_LEADER_FILTER_BY_PROPOSER=0 to fall back to the old
        // signer-based judgment (emergency toggle; keeps sign-but-never-
        // produce externals in the rotation, so they dead-slot).
        byProposer: process.env.ASENTUM_V3_LEADER_FILTER_BY_PROPOSER !== "0"
      }) : views;
      const filterVoters = V3_VOTER_FILTER_FROM_EPOCH != null && due.epoch >= V3_VOTER_FILTER_FROM_EPOCH;
      const votingViews = filterVoters ? await this.applyV3Dormancy(due.epoch, views, { selfKeep: false, byProposer: false }) : views;
      if (due.epoch <= this.v3LastScheduledEpoch) return;
      if (votingViews.length === 0 || leaderViews.length === 0) return;
      const committee = votingViews.map((v) => hexToBytes11(v.address));
      const stake = votingViews.map((v) => BigInt(v.stake));
      const leaderCommittee = filterLeaders ? leaderViews.map((v) => hexToBytes11(v.address)) : filterVoters ? views.map((v) => hexToBytes11(v.address)) : void 0;
      this.v3.engine.reconfigureCommittee(due.fromHeight, committee, stake, leaderCommittee);
      this.v3LastScheduledEpoch = due.epoch;
      console.log(
        `[v3-reconfigure] scheduled epoch=${due.epoch} fromHeight=${due.fromHeight} committee=${committee.length}/${views.length} leaders=${(leaderCommittee ?? committee).length}${filterVoters ? ` (voter-filter on: dropped ${views.length - votingViews.length})` : ""}${filterLeaders ? "" : " (leader-filter off)"}`
      );
    } catch (err) {
      console.warn(
        `[v3-reconfigure] deferred at applied=${appliedHeight}: ${err.message}`
      );
    }
  }
  /**
   * Distribute the per-block validator rewards for a freshly committed
   * block. Reads signers from the engine (validators whose precommits
   * landed in the canonical block), computes the 5/80/15 split with the
   * 10% per-validator cap, and applies the resulting payouts as account
   * balance changes — debiting the rewards pool, crediting each recipient.
   *
   * Call this AFTER the block is persisted but BEFORE
   * `resetConsensusForNextHeight` (which wipes the engine's signer state).
   * Idempotent on a per-block basis: calling twice would double-credit, so
   * the chain-level commit path must call exactly once.
   *
   * For v1: warm-inactive list is computed from the cached committee
   * (validators bonded with `epoch_entry` for this epoch but NOT in the
   * active committee). For dev-cluster scale (4 baked validators, well
   * under cap), this is always empty.
   */
  /**
   * Apply the block-level subsidies (per-block rewards + vested-vesting
   * releases) to the given store. Called as part of block APPLY — inside
   * the journal, before computing the block's stateRoot — so the
   * resulting state mutations are encoded into the block header. Every
   * validator applying the same block to the same starting state arrives
   * at byte-identical post-state. (Previously this ran AFTER commit on
   * the live store, leaving a window where the block's stateRoot
   * excluded subsidies and validators diverged at the next block.)
   *
   * Inputs:
   *   - `store`:           the journal/state to mutate (caller's choice).
   *   - `currentBlockNumber`: the block being applied. Used as the "now"
   *                        clock for vesting release math.
   *   - `lastCommitBitmap`: the bitmap from the block being applied. Lists
   *                        signers of the PREVIOUS block, who get credit.
   *
   * Subsidies for the FIRST block since node startup (`prevBlockMeta` is
   * null) skip the rewards step — there's no preceding block we have
   * meta for. Vesting release still runs (and is a no-op on a chain
   * with no vesting buckets yet).
   */
  async applyBlockSubsidies(store, currentBlockNumber, lastCommitBitmap, lastCommit, prevMetaOverride) {
    const DEBUG = process.env.ASENTUM_DEBUG_APPLY_SUBSIDIES === "1";
    const trace = (msg, obj) => {
      if (!DEBUG) return;
      const tag = `[apply-trace h=${currentBlockNumber}]`;
      if (obj === void 0) console.log(`${tag} ${msg}`);
      else console.log(`${tag} ${msg} ` + JSON.stringify(obj, (_k, v) => typeof v === "bigint" ? v.toString() : v));
    };
    const prevMeta = prevMetaOverride !== void 0 ? prevMetaOverride : this.prevBlockMeta;
    if (prevMeta && lastCommitBitmap.length > 0) {
      const prev = prevMeta;
      trace("prevBlockMeta", {
        blockNumber: prev.blockNumber,
        proposer: bytesToHex16(prev.proposer),
        committeeAddresses: prev.committeeAddresses,
        committeeStakes: Object.fromEntries(
          Array.from(prev.committeeStakes.entries()).map(([k, v]) => [k, v.toString()])
        )
      });
      trace("lastCommitBitmap", bytesToHex16(lastCommitBitmap));
      let signerAddrs;
      if (lastCommit && lastCommit.signatures.length > 0) {
        signerAddrs = lastCommit.signatures.map((s) => prev.committeeAddresses[s.validatorIdx]?.toLowerCase()).filter((a) => a !== void 0);
        trace("cert-derived signers", signerAddrs);
      } else {
        try {
          signerAddrs = decodeLastCommitBitmap(lastCommitBitmap, prev.committeeAddresses);
        } catch (err) {
          console.error("[rewards] failed to decode lastCommitBitmap:", err);
          signerAddrs = [];
        }
        trace("bitmap-decoded signers", signerAddrs);
      }
      if (prev.committeeAddresses.length > 0) {
        this.recordBlockLiveness(
          prev.committeeAddresses,
          signerAddrs,
          BigInt(prev.blockNumber)
        );
      }
      if (signerAddrs.length > 0) {
        const signers = signerAddrs.map((addr) => ({
          address: addr,
          stake: prev.committeeStakes.get(addr) ?? 0n
        }));
        const tWarmStart = Date.now();
        const warmInactive = await this.computeWarmInactiveForReward(prev.blockNumber);
        const tWarmEnd = Date.now();
        trace("warmInactive", warmInactive);
        const proposerHex = bytesToHex16(prev.proposer).toLowerCase();
        const distribution = distributeBlockReward({
          blockNumber: prev.blockNumber,
          proposer: proposerHex,
          signers,
          warmInactive
        });
        trace("distribution", {
          emitted: distribution.emitted.toString(),
          payouts: Array.from(distribution.payouts.entries()).map(
            ([addr, amount]) => [addr, amount.toString()]
          )
        });
        const enc = new TextEncoder();
        const committeeHash = bytesToHex16(
          blake318(enc.encode(prev.committeeAddresses.join(",")))
        ).slice(2, 10);
        const signersHash = bytesToHex16(
          blake318(enc.encode(signerAddrs.slice().sort().join(",")))
        ).slice(2, 10);
        const payoutHash = bytesToHex16(
          blake318(enc.encode(
            Array.from(distribution.payouts.entries()).map(([a, v]) => `${a}=${v.toString()}`).sort().join("|")
          ))
        ).slice(2, 10);
        console.log(
          `[drift-trace] h=${currentBlockNumber} prevH=${prev.blockNumber} proposer=${proposerHex.slice(2, 10)} committee=${committeeHash}(${prev.committeeAddresses.length}) bitmap=${bytesToHex16(lastCommitBitmap)} signers=${signersHash}(${signerAddrs.length}) emittedWei=${distribution.emitted.toString()} payouts=${payoutHash}`
        );
        if (process.env.ASENTUM_DEBUG_APPLY_DUMP === "1") {
          try {
            const epoch = prev.blockNumber / EPOCH_BLOCKS;
            const [epochCandidates, tombstones] = await Promise.all([
              this.getEpochCandidatesFiltered(epoch).catch(() => null),
              this.getTombstones().catch(() => null)
            ]);
            this.lastSubsidyInputs = {
              prevBlockNumber: prev.blockNumber.toString(),
              prevProposer: proposerHex,
              prevCommitteeAddresses: [...prev.committeeAddresses],
              prevCommitteeStakes: Object.fromEntries(
                Array.from(prev.committeeStakes.entries()).map(([k, v]) => [k, v.toString()])
              ),
              prevLocallyObservedSigners: [...prev.locallyObservedSigners],
              bitmapHex: bytesToHex16(lastCommitBitmap),
              decodedSigners: [...signerAddrs],
              warmInactive: warmInactive.map((w) => ({
                address: w.address,
                stake: w.stake.toString()
              })),
              distribution: {
                emitted: distribution.emitted.toString(),
                payouts: Array.from(distribution.payouts.entries()).map(
                  ([addr, amount]) => [addr, amount.toString()]
                )
              },
              epoch: epoch.toString(),
              epochCandidates: Array.isArray(epochCandidates) ? epochCandidates.map(
                (c) => typeof c === "string" ? c : c.address ?? String(c)
              ) : null,
              tombstones: Array.isArray(tombstones) ? tombstones.map(
                (t) => typeof t === "string" ? t : t.address ?? String(t)
              ) : null
            };
          } catch (err) {
            console.error("[apply-dump] failed to capture inputs:", err);
            this.lastSubsidyInputs = null;
          }
        }
        const tDistEnd = Date.now();
        if (distribution.emitted > 0n) {
          await this.applyRewardPayoutsTo(store, distribution);
        }
        const tPayoutsEnd = Date.now();
        if (tPayoutsEnd - tWarmStart > 100) {
          console.log(
            `[subsidies-timing] h=${currentBlockNumber} warm=${tWarmEnd - tWarmStart}ms distribute=${tDistEnd - tWarmEnd}ms payouts=${tPayoutsEnd - tDistEnd}ms signers=${signerAddrs.length}`
          );
        }
      }
    } else {
      trace("skip rewards (prevBlockMeta=null OR empty bitmap)");
    }
    trace("vesting release: SKIPPED (feature disabled)");
  }
  /**
   * Run due cron jobs. Called from buildBlock's applyCronHook with
   * the block's store + timestamp. Walks the cron registry's due
   * index, dispatches each due job's target call, then bills the
   * registry via recordRun. Bounded by CRON_BLOCK_GAS_BUDGET so a
   * pathological backlog can't stall block production.
   *
   * Determinism rules:
   *  - timestamp is the BLOCK timestamp (not wall-clock).
   *  - jobs run in id-ascending order as returned by listDue.
   *  - on any error inside a single job, the job is still billed and
   *    recorded as a failure; the block continues.
   */
  async applyCronJobs(store, blockNumber, blockTimestamp) {
    if (process.env.ASENTUM_DISABLE_CRON === "1") return;
    const dueResult = await runChainCall({
      store,
      vm: this.vm,
      contractAddress: CRON_REGISTRY_ADDRESS,
      method: "listDue",
      args: [blockTimestamp.toString(), "100"],
      blockNumber,
      blockTimestamp,
      chainId: this.chainId
    });
    if (!dueResult.ok) {
      console.warn("[cron] listDue failed:", dueResult.reason);
      return;
    }
    const ids = Array.isArray(dueResult.returnValue) ? dueResult.returnValue : [];
    if (ids.length === 0) return;
    let budgetLeft = CRON_BLOCK_GAS_BUDGET;
    for (const id of ids) {
      if (budgetLeft <= 0n) break;
      const jobRead = await runChainCall({
        store,
        vm: this.vm,
        contractAddress: CRON_REGISTRY_ADDRESS,
        method: "getJob",
        args: [id],
        blockNumber,
        blockTimestamp,
        chainId: this.chainId
      });
      if (!jobRead.ok || !jobRead.returnValue) continue;
      const job = jobRead.returnValue;
      const jobGas = BigInt(job.gasLimit);
      const allowed = jobGas > budgetLeft ? budgetLeft : jobGas;
      let success = true;
      let gasUsed = 0n;
      try {
        const target = new Uint8Array(20);
        const hex2 = job.target.replace(/^0x/, "").toLowerCase();
        for (let i = 0; i < 20; i++) target[i] = parseInt(hex2.slice(i * 2, i * 2 + 2), 16);
        const r = await runChainCall({
          store,
          vm: this.vm,
          contractAddress: target,
          method: job.method,
          args: job.args ?? [],
          blockNumber,
          blockTimestamp,
          chainId: this.chainId,
          gasLimit: allowed
        });
        success = r.ok;
        gasUsed = r.ok ? r.gasUsed : allowed;
      } catch (err) {
        console.warn(`[cron] dispatch failed for job ${id}:`, err);
        success = false;
        gasUsed = allowed;
      }
      budgetLeft -= gasUsed;
      const billed = await runChainCall({
        store,
        vm: this.vm,
        contractAddress: CRON_REGISTRY_ADDRESS,
        method: "recordRun",
        args: [id, gasUsed.toString(), success],
        blockNumber,
        blockTimestamp,
        chainId: this.chainId
      });
      if (!billed.ok) {
        console.warn(`[cron] recordRun failed for ${id}:`, billed.reason);
      }
    }
  }
  /**
   * Capture metadata needed to distribute the just-committed block's
   * rewards on the NEXT block's commit. Reads the engine's view of the
   * committee (active for the just-committed block) and locally observed
   * signers (will be empty for sync-mode replicas — they don't have a
   * gossip log, but they don't need it either; only the producer needs it
   * to BUILD the next block's bitmap, which the rest then verify-or-trust).
   */
  async captureCurrentBlockMeta(block, blockHash) {
    const voterEpoch = block.header.number / EPOCH_BLOCKS;
    const filterVoters = V3_VOTER_FILTER_FROM_EPOCH != null && voterEpoch >= V3_VOTER_FILTER_FROM_EPOCH;
    const bonded = filterVoters ? await this.applyV3Dormancy(
      voterEpoch,
      await this.computeCommitteeForEpoch(voterEpoch, { deterministic: true }),
      { selfKeep: false, byProposer: false }
    ) : await this.fetchActiveValidatorViews();
    const committeeAddresses = bonded.map((v) => v.address.toLowerCase());
    const committeeStakes = /* @__PURE__ */ new Map();
    for (const v of bonded) {
      try {
        committeeStakes.set(v.address.toLowerCase(), BigInt(v.stake));
      } catch {
        committeeStakes.set(v.address.toLowerCase(), 0n);
      }
    }
    const locallyObservedSigners = this.consensusEngine.getCommittedSigners(blockHash);
    if (process.env.ASENTUM_DEBUG_PREVBLOCKMETA === "1") {
      const stakesObj = {};
      for (const [addr, stake] of committeeStakes) stakesObj[addr] = stake.toString();
      console.log(`[prevBlockMeta:capture] h=${block.header.number} ` + JSON.stringify({
        proposer: bytesToHex16(block.header.proposer),
        committeeAddresses,
        committeeStakes: stakesObj,
        locallyObservedSignersCount: locallyObservedSigners.length
      }));
    }
    return {
      blockNumber: block.header.number,
      proposer: block.header.proposer,
      committeeAddresses,
      committeeStakes,
      locallyObservedSigners
    };
  }
  /**
   * Reconstruct prevBlockMeta after a node restart, deterministically
   * from the staking contract. Independent of in-memory engine state.
   *
   * The engine has already been advanced to the next block's committee
   * by the time start() reaches this code, so getValidators() returns
   * the wrong set. We compute the committee for the just-committed
   * block's epoch the same way chooseCommittee does at runtime, so
   * decodeLastCommitBitmap resolves the same addresses on every node.
   *
   * locallyObservedSigners is left empty here. This node did not
   * participate in the BFT cycle for the prior block, so it has no
   * local observation. Apply reads the next block's bitmap from the
   * header, so this is fine. The only side effect is that if THIS
   * node becomes the next proposer, it builds an empty bitmap and
   * one block of rewards is skipped. The chain does not diverge.
   */
  async reconstructPrevBlockMeta(header) {
    const epoch = header.number / EPOCH_BLOCKS;
    const filterVoters = V3_VOTER_FILTER_FROM_EPOCH != null && epoch >= V3_VOTER_FILTER_FROM_EPOCH;
    let committee;
    try {
      const raw = await this.computeCommitteeForEpoch(epoch, { deterministic: true });
      committee = filterVoters ? await this.applyV3Dormancy(epoch, raw, { selfKeep: false, byProposer: false }) : raw;
    } catch (err) {
      console.error(
        `[startup] reconstructPrevBlockMeta: committee lookup for epoch ${epoch} failed; reward distribution on the next block will be skipped: ${err.message}`
      );
      committee = [];
    }
    const committeeAddresses = committee.map((v) => v.address.toLowerCase());
    const committeeStakes = /* @__PURE__ */ new Map();
    for (const v of committee) {
      committeeStakes.set(v.address.toLowerCase(), BigInt(v.stake));
    }
    if (process.env.ASENTUM_DEBUG_PREVBLOCKMETA === "1") {
      const stakesObj = {};
      for (const [addr, stake] of committeeStakes) stakesObj[addr] = stake.toString();
      console.log(`[prevBlockMeta:reconstruct] h=${header.number} ` + JSON.stringify({
        proposer: bytesToHex16(header.proposer),
        committeeAddresses,
        committeeStakes: stakesObj,
        locallyObservedSignersCount: 0
      }));
    }
    return {
      blockNumber: header.number,
      proposer: header.proposer,
      committeeAddresses,
      committeeStakes,
      locallyObservedSigners: []
    };
  }
  /**
   * #417: the single deterministic prevBlockMeta source for v3 reward
   * application. For a block N whose parent is `parentHeader` (block N-1),
   * returns block N-1's committee + proposer, reconstructed purely from
   * on-chain header data. Every path that applies block N's subsidies
   * (produce, rebuild-replay, finalized-commit, cold-follower verify) feeds
   * the identical value here, so the reward — and therefore the state root —
   * is byte-identical everywhere. Returns null when the parent is genesis
   * (block 1 has no prior committee to reward), matching the pre-#417
   * first-block skip.
   */
  async v3PrevMetaForParent(parentHeader) {
    if (parentHeader.number === 0n) return null;
    return this.reconstructPrevBlockMeta(parentHeader);
  }
  /**
   * Build the lastCommitBitmap for the block we're about to propose,
   * using prevBlockMeta to know which committee positions to set. Called
   * from produceBlockInner when constructing a fresh block.
   *
   * Returns an all-zero bitmap if prevBlockMeta is null (first block
   * after startup, or block 1 of a fresh genesis).
   */
  /**
   * Cached bitmap for the height we're currently building. MUST be stable
   * across all rounds of that height — see #277.
   *
   * Without this cache, `buildBitmapForNextBlock` would re-read
   * `getLateCommittedSigners()` on every round, which mutates as late
   * precommits arrive between commits. A round-0 proposal with N signers
   * and a round-1 proposal with N+k signers produce DIFFERENT bitmaps,
   * DIFFERENT reward payouts, DIFFERENT state roots, DIFFERENT block
   * hashes — at which point any validator that locked on round-0's hash
   * cannot re-prevote round-1's hash (Tendermint locked-prevote rule),
   * polka never forms, round runs away, chain wedges.
   *
   * Verified prod root-cause for the 2026-06-17 stall on h=34654 via
   * drift-trace evidence: two payouts hashes (a179962a / 42d378cf)
   * for the same proposer at the same height across rounds. Memoizing
   * here freezes the bitmap so the proposer produces the same block on
   * round N as on round N+1 — locked validators can finally re-vote.
   *
   * Invalidates naturally when prevBlockMeta advances to the next height
   * (the cached key no longer matches).
   */
  cachedBitmapForHeight = null;
  buildBitmapForNextBlock() {
    if (!this.prevBlockMeta) return emptyLastCommitBitmap();
    if (isLPCEngine()) return emptyLastCommitBitmap();
    const nextHeight = this.prevBlockMeta.blockNumber + 1n;
    if (this.cachedBitmapForHeight?.height === nextHeight) {
      return this.cachedBitmapForHeight.bitmap;
    }
    const lateSigners = this.consensusEngine.getLateCommittedSigners();
    const merged = new Set(this.prevBlockMeta.locallyObservedSigners);
    for (const addr of lateSigners) merged.add(addr);
    const bitmap = buildLastCommitBitmap(
      this.prevBlockMeta.committeeAddresses,
      Array.from(merged)
    );
    this.cachedBitmapForHeight = { height: nextHeight, bitmap };
    return bitmap;
  }
  /**
   * Build the on-chain LastCommit cert for the block about to be produced.
   *
   * Reads the actual precommit VOTES that formed 2/3-stake quorum on the
   * previously-committed block (h-1) from the engine, maps each vote's
   * validator address to its index in the h-1 committee's deterministic
   * order, and emits `LastCommitSig[]`. Result → passed to buildBlock
   * → embedded in block.body.lastCommit → hashed into header.lastCommitHash.
   *
   * See notes/last-commit-cert-design-2026-07-01.md. This is the root-cause
   * fix for the bitmap non-determinism observed in prod (h=36646
   * 2026-07-01): every applying node reads signers from this cert instead
   * of re-deriving locally.
   *
   * Returns `emptyLastCommit()` when:
   *   - no prev block yet (block 1)
   *   - the engine has no committed precommits captured (fresh boot before
   *     first quorum reached)
   */
  buildLastCommitForNextBlock() {
    if (!this.prevBlockMeta) return emptyLastCommit3();
    const committed = this.consensusEngine.getCommittedPrecommits();
    if (!committed || committed.votes.length === 0) {
      return emptyLastCommit3();
    }
    const committee = this.prevBlockMeta.committeeAddresses;
    const addressToIdx = /* @__PURE__ */ new Map();
    for (let i = 0; i < committee.length; i++) {
      addressToIdx.set(committee[i].toLowerCase(), i);
    }
    const signatures = [];
    for (const vote of committed.votes) {
      const voterHex = "0x" + Array.from(vote.body.validator).map((b) => b.toString(16).padStart(2, "0")).join("");
      const idx = addressToIdx.get(voterHex);
      if (idx === void 0) {
        console.warn(
          `[last-commit-build] dropping vote from non-committee validator ${voterHex.slice(0, 14)}\u2026`
        );
        continue;
      }
      signatures.push({
        validatorIdx: idx,
        publicKey: vote.publicKey,
        signature: vote.signature
      });
    }
    signatures.sort((a, b) => a.validatorIdx - b.validatorIdx);
    return {
      blockHash: committed.blockHash,
      round: committed.round,
      signatures
    };
  }
  /**
   * Verify a block's on-chain LastCommit cert. Called from
   * applyExternalBlockTentative BEFORE any state mutation. Returns null
   * on success, or an error string describing which check failed.
   *
   * Checks:
   *   1. `block.header.lastCommitHash === blake3(SSZ(block.lastCommit))`
   *   2. `block.lastCommit.blockHash === expectedParentHash` (block h-1)
   *   3. Each signature is a valid Dilithium3 signature over the correct
   *      precommit-vote body (kind, chainId, h-1, round, blockHash,
   *      validator) using the pubkey embedded in the sig.
   *   4. Signed stake ≥ 2/3 of the h-1 committee's total stake.
   *   5. All validatorIdx values refer to slots that exist in the h-1
   *      committee, and the derived address matches expectations.
   *
   * See notes/last-commit-cert-design-2026-07-01.md.
   */
  verifyLastCommit(block, expectedParentHash) {
    const cert = block.lastCommit;
    if (!cert) return "missing lastCommit field";
    if (cert.signatures.length === 0) {
      console.warn(
        `[verify-cert] WARN empty-cart accepted at h=${block.header.number} \u2014 proposer had no restored cert; expect one block of missed rewards`
      );
      return null;
    }
    const certHash = blake318(LastCommitSchema2.serialize(cert));
    if (!bytesEqual13(certHash, block.header.lastCommitHash)) {
      return `header.lastCommitHash ${bytesToHex16(block.header.lastCommitHash).slice(0, 14)}\u2026 != blake3(SSZ(lastCommit)) ${bytesToHex16(certHash).slice(0, 14)}\u2026`;
    }
    if (!bytesEqual13(cert.blockHash, expectedParentHash)) {
      return `lastCommit.blockHash ${bytesToHex16(cert.blockHash).slice(0, 14)}\u2026 != expected parent ${bytesToHex16(expectedParentHash).slice(0, 14)}\u2026`;
    }
    if (!this.prevBlockMeta || this.prevBlockMeta.blockNumber !== block.header.number - 1n) {
      return null;
    }
    const committee = this.prevBlockMeta.committeeAddresses;
    const stakes = this.prevBlockMeta.committeeStakes;
    let totalStake2 = 0n;
    for (const addr of committee) totalStake2 += stakes.get(addr) ?? 0n;
    const threshold = totalStake2 * 2n / 3n;
    let signedStake = 0n;
    const seenIdx = /* @__PURE__ */ new Set();
    let prevIdx = -1;
    for (const sig of cert.signatures) {
      if (sig.validatorIdx < 0 || sig.validatorIdx >= committee.length) {
        return `validatorIdx ${sig.validatorIdx} out of range for committee size ${committee.length}`;
      }
      if (seenIdx.has(sig.validatorIdx)) {
        return `duplicate validatorIdx ${sig.validatorIdx}`;
      }
      seenIdx.add(sig.validatorIdx);
      if (sig.validatorIdx <= prevIdx) {
        return `signatures not sorted ascending by validatorIdx`;
      }
      prevIdx = sig.validatorIdx;
      const derived = bytesToHex16(addressBytesFromPublicKey8(sig.publicKey)).toLowerCase();
      const expectedAddr = committee[sig.validatorIdx].toLowerCase();
      if (derived !== expectedAddr) {
        return `sig[${sig.validatorIdx}]: pubkey\u2192address ${derived.slice(0, 14)}\u2026 does not match committee[${sig.validatorIdx}] ${expectedAddr.slice(0, 14)}\u2026`;
      }
      const voteBody = {
        kind: VOTE_KIND_PRECOMMIT3,
        chainId: this.chainId,
        height: block.header.number - 1n,
        round: cert.round,
        blockHash: cert.blockHash,
        validator: hexToBytes11(expectedAddr)
      };
      const digest = blake318(VoteBodySchema2.serialize(voteBody));
      const ok = verifyDilithium3(digest, sig.signature, sig.publicKey);
      if (!ok) {
        return `sig[${sig.validatorIdx}] Dilithium3 verification failed`;
      }
      signedStake += stakes.get(expectedAddr) ?? 0n;
    }
    if (signedStake < threshold) {
      return `signed stake ${signedStake} < 2/3 threshold ${threshold} of committee total ${totalStake2}`;
    }
    return null;
  }
  /**
   * Compute the warm-inactive set for this block: bonded validators who
   * submitted an `epoch_entry` for the current epoch but didn't make it
   * into the active committee. They earn the 15% slice pro-rata.
   *
   * Returns [] when:
   *   - The chain is under the committee cap (everyone bonded is active)
   *   - The staking contract returned no eligible reserve for this epoch
   *   - The query failed (defensive — better to roll the 15% to signers
   *     than to crash the commit path)
   */
  async computeWarmInactiveForReward(blockNum) {
    try {
      const tStart = Date.now();
      const epoch = blockNum / EPOCH_BLOCKS;
      const candidates = await this.getEpochCandidatesFiltered(epoch);
      const tCandidates = Date.now();
      const committeeCacheHit = this.v3DetCommitteeCache.has(String(epoch));
      const committee = await this.computeCommitteeForEpoch(epoch, { deterministic: true });
      const tCommittee = Date.now();
      const inCommittee = new Set(committee.map((v) => v.address.toLowerCase()));
      const out = candidates.reserve.filter((v) => !inCommittee.has(v.address.toLowerCase())).map((v) => ({ address: v.address.toLowerCase(), stake: BigInt(v.stake) }));
      const tEnd = Date.now();
      if (tEnd - tStart > 50) {
        console.log(
          `[warm-inactive-timing] h=${blockNum} total=${tEnd - tStart}ms candidates=${tCandidates - tStart}ms committee=${tCommittee - tCandidates}ms filter=${tEnd - tCommittee}ms cacheHit=${committeeCacheHit} reserveCount=${candidates.reserve.length} committeeCount=${committee.length}`
        );
      }
      return out;
    } catch (err) {
      console.error("[rewards] computeWarmInactive failed (treating as empty):", err);
      return [];
    }
  }
  /**
   * Apply a reward distribution as on-chain balance changes. Debits the
   * rewards pool; credits each recipient by the computed amount. Mutations
   * are written through the live state store.
   *
   * Insufficient pool balance shouldn't happen in practice (the pool was
   * pre-funded at genesis with 150M and per-block emission is bounded by
   * the schedule), but if it does we fall back to crediting whatever the
   * pool has and warn — better degraded than crashed.
   */
  async applyRewardPayoutsTo(store, distribution) {
    const poolAccount = await store.getOrCreate(REWARDS_POOL_ADDRESS);
    if (poolAccount.balance < distribution.emitted) {
      console.error(
        `[rewards] pool underflow: needs ${distribution.emitted} but has ${poolAccount.balance}; skipping block ${distribution.blockNumber}`
      );
      return;
    }
    poolAccount.balance -= distribution.emitted;
    await store.setAccount(REWARDS_POOL_ADDRESS, poolAccount);
    let toVestingPool = 0n;
    for (const [addrHex, amount] of distribution.payouts) {
      if (amount <= 0n) continue;
      const recipientAddr = hexToBytes11(addrHex);
      const account = await store.getOrCreate(recipientAddr);
      account.balance += amount;
      await store.setAccount(recipientAddr, account);
    }
    void toVestingPool;
  }
  /**
   * Is the given address one of the 4 baked-genesis founding validators?
   * Their earnings are routed through the vesting pool instead of being
   * credited directly to their wallet.
   */
  isFoundingValidator(addressHex) {
    const lower = addressHex.toLowerCase();
    for (const v of BAKED_GENESIS_VALIDATORS) {
      if (v.address.toLowerCase() === lower) return true;
    }
    return false;
  }
  /**
   * Walk founding-validator vesting buckets, release any newly-vested
   * portion to their wallets. Runs every block — cheap because each
   * walk is bounded by (VESTING_BLOCKS / EPOCH_BLOCKS) buckets per
   * recipient (≈4,320 max at mainnet, dramatically fewer in dev).
   *
   * For mainnet we may want to amortize this to epoch boundaries (the
   * release granularity doesn't need to be per-block), but for v1
   * the per-block walk keeps the math simple and the tests trivial.
   */
  async releaseFoundingValidatorVestingTo(store, currentBlock) {
    const recipients = BAKED_GENESIS_VALIDATORS.map((v) => v.address.toLowerCase());
    const result = await computeAndRecordRelease(store, recipients, currentBlock);
    if (result.totalReleased === 0n) return;
    const poolAccount = await store.getOrCreate(VESTING_POOL_ADDRESS);
    if (poolAccount.balance < result.totalReleased) {
      console.error(
        `[vesting] pool underflow: needs ${result.totalReleased} but has ${poolAccount.balance}; skipping release`
      );
      return;
    }
    poolAccount.balance -= result.totalReleased;
    await store.setAccount(VESTING_POOL_ADDRESS, poolAccount);
    for (const [addrHex, amount] of result.released) {
      const recipientAddr = hexToBytes11(addrHex);
      const account = await store.getOrCreate(recipientAddr);
      account.balance += amount;
      await store.setAccount(recipientAddr, account);
    }
  }
  /**
   * If the just-committed block sits at the FIRST block of the epoch's
   * entry window, auto-submit a `submitEpochEntry` tx for the next
   * epoch. Fires once per epoch per validator (idempotent on the
   * contract side) so a validator that runs continuously through the
   * window stays in the rotating-reserve eligibility pool without
   * operator intervention.
   *
   * Skipped silently when:
   *   - We're not bonded (no validator key, or the address isn't in
   *     the staking contract's bonded set).
   *   - The window has already closed (next-epoch entries arrive late
   *     and would just revert).
   *   - The just-committed block isn't at the window's edge — we only
   *     submit on the entry-block boundary, not every block in the window.
   */
  /**
   * Tracks epochs we've ALREADY SUBMITTED for and the (nonce, height) of
   * the submission. Replaces the previous `submittedEpochs: Set<string>`
   * which marked an epoch attempted BEFORE the tx was known to have been
   * accepted. That older design wedged validators across multiple
   * epochs whenever a single submission failed silently — proven in prod
   * 2026-06-19/20 (Hilsboro missed four consecutive entry windows after
   * a single rejection).
   *
   * Determinism: keyed on target epoch, value is the chain-derivable
   * confirmation signal (compare against the live nonce). In-memory only;
   * after restart we re-derive correctly because the chain nonce reflects
   * any prior successful submission.
   */
  pendingEpochEntry = /* @__PURE__ */ new Map();
  /**
   * If the just-committed block sits inside the epoch's entry window,
   * auto-submit a `submitEpochEntry` tx for the next epoch. Fires once
   * per epoch per validator (the contract dedupes on its side; retries
   * across windows are harmless).
   *
   * Originally fired only on the EXACT `windowOpensAt` block — but that
   * design assumed every validator commits every block via the local BFT
   * cycle. Pull-mode (NAT'd Operator users) advances state via block-sync
   * and never sees a "the height I'm at equals windowOpensAt" event from
   * the BFT commit path. Result: they bond, pay 50K ASE, and silently
   * never enter the active committee. So now: fire on ANY observed
   * height inside the window, and gate redundant submits on whether
   * the chain has actually accepted a prior attempt.
   */
  async maybeSubmitEpochEntry(committedHeight) {
    if (process.env.ASENTUM_DISABLE_EPOCH_ENTRY === "1") return;
    const currentEpoch = committedHeight / EPOCH_BLOCKS;
    const epochStart = currentEpoch * EPOCH_BLOCKS;
    const windowOpensAt = epochStart + EPOCH_BLOCKS - EPOCH_ENTRY_WINDOW;
    const epochEnds = epochStart + EPOCH_BLOCKS;
    if (committedHeight < windowOpensAt || committedHeight >= epochEnds) return;
    const targetEpoch = currentEpoch + 1n;
    const epochKey = String(targetEpoch);
    const account = await this.stateStore.getAccount(this.validatorKey.address);
    const chainNonce = account?.nonce ?? 0n;
    const pending = this.pendingEpochEntry.get(epochKey);
    if (pending) {
      if (chainNonce > pending.submittedAtNonce) return;
      const REWRITE_AFTER_BLOCKS = 30n;
      if (committedHeight - pending.submittedAtHeight < REWRITE_AFTER_BLOCKS) return;
      this.pendingEpochEntry.delete(epochKey);
    }
    const senderHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    try {
      const tx = buildAndSignCall({
        senderKeypair: this.validatorKey.keypair,
        chainId: this.chainId,
        nonce: chainNonce,
        to: STAKING_CONTRACT_ADDRESS,
        value: 0n,
        gasLimit: 1000000n,
        method: "submitEpochEntry",
        args: [String(targetEpoch)]
      });
      const result = this.submitTransaction(tx);
      if (!result.accepted) {
        console.warn(
          `[epoch-entry] auto-submit rejected for epoch ${targetEpoch}: ${result.reason} (will retry within window)`
        );
      } else {
        this.pendingEpochEntry.set(epochKey, {
          submittedAtNonce: chainNonce,
          submittedAtHeight: committedHeight
        });
        console.log(
          `[epoch-entry] submitted for epoch ${targetEpoch} (validator ${senderHex.slice(0, 14)}\u2026, nonce ${chainNonce})`
        );
      }
    } catch (err) {
      console.error("[epoch-entry] build/submit failed:", err);
    }
  }
  /**
   * Wraps `getEpochCandidates` (the staking-contract method) with a
   * tombstone filter — slashed validators are removed from BOTH core
   * and reserve before the consensus layer ever sees them. Without
   * this, a tombstoned validator could remain in a cached committee
   * for the rest of the epoch and contribute zero stake to quorum,
   * dragging down liveness.
   */
  async getEpochCandidatesFiltered(epoch) {
    if (this.cachedCandidates && this.cachedCandidates.epoch === epoch) {
      void this.saveFrozenCandidates(epoch, this.cachedCandidates.candidates);
      return this.cachedCandidates.candidates;
    }
    const frozen = await this.loadFrozenCandidates(epoch);
    if (frozen) {
      this.cachedCandidates = { epoch, candidates: frozen };
      return frozen;
    }
    let candidates;
    const seedBlockNumber = epoch === 0n ? 0n : epoch * EPOCH_BLOCKS - 1n;
    const seed = await this.resolveSeedReadState(seedBlockNumber);
    if (epoch !== 0n && seed === null) {
      throw new SeedBlockUnavailableError(epoch, seedBlockNumber);
    }
    const seedStore = seed ? seed.store : this.stateStore;
    const queryBlockNumber = seedBlockNumber;
    const queryBlockTimestamp = seed ? seed.timestamp : this.latestHeader.timestamp;
    try {
      const stakingResult = await runReadOnlyCall({
        store: seedStore,
        vm: this.vm,
        contractAddress: STAKING_CONTRACT_ADDRESS,
        method: "getEpochCandidates",
        args: [String(epoch)],
        blockNumber: queryBlockNumber,
        blockTimestamp: queryBlockTimestamp,
        chainId: this.chainId
      });
      if (!stakingResult.ok) {
        const flat = await this.getActiveValidators();
        return { core: flat, reserve: [] };
      }
      candidates = stakingResult.returnValue ?? { core: [], reserve: [] };
    } catch (err) {
      console.error("[consensus] getEpochCandidates failed:", err);
      return { core: [], reserve: [] };
    }
    let tombstoned;
    try {
      const tombstonePin = epoch === 0n ? void 0 : {
        blockNumber: seedBlockNumber,
        blockTimestamp: queryBlockTimestamp,
        store: seedStore
      };
      const tombs = await this.getTombstones(tombstonePin);
      tombstoned = /* @__PURE__ */ new Set();
      for (const t of tombs) {
        const raw = (t.slashedAt ?? "").trim();
        if (raw.length === 0) continue;
        let slashedAt;
        try {
          slashedAt = BigInt(raw);
        } catch {
          continue;
        }
        if (slashedAt <= seedBlockNumber) {
          tombstoned.add(t.address.toLowerCase());
        }
      }
    } catch {
      tombstoned = /* @__PURE__ */ new Set();
    }
    const seedHeight = seedBlockNumber;
    const bondedBySeed = (v) => {
      let bondedAt;
      try {
        bondedAt = BigInt((v.bondedAt ?? "0").trim() || "0");
      } catch {
        return true;
      }
      if (bondedAt === 0n) return true;
      return seedHeight - bondedAt >= V3_PENDING_BLOCKS;
    };
    const out = {
      core: (candidates.core ?? []).filter(
        (v) => !tombstoned.has(v.address.toLowerCase()) && bondedBySeed(v)
      ),
      reserve: (candidates.reserve ?? []).filter(
        (v) => !tombstoned.has(v.address.toLowerCase()) && bondedBySeed(v)
      )
    };
    this.cachedCandidates = { epoch, candidates: out };
    void this.saveFrozenCandidates(epoch, out);
    return out;
  }
  /**
   * #419 committee-freeze persistence. The per-epoch committee is written
   * once (first computation, epoch-start for continuously-running nodes) to
   * `${dataDir}/epoch-committee/<epoch>.json`. It lives in the data dir so
   * the snapshot tar carries it — a snapshot/restart-onboarded node LOADS the
   * frozen set (`loadFrozenCandidates`) rather than recomputing from current
   * stakes, which is the #419 divergence. Write-if-absent: never overwrites
   * the authoritative first-frozen value with a later mid-epoch recompute.
   */
  async saveFrozenCandidates(epoch, candidates) {
    try {
      const dir = join6(this.dataDir, "epoch-committee");
      const path3 = join6(dir, `${epoch}.json`);
      try {
        const existing = JSON.parse(await readFile6(path3, "utf-8"));
        if (existing?.chainId === void 0 || existing.chainId === this.chainId.toString()) return;
      } catch {
      }
      await mkdir5(dir, { recursive: true });
      await writeFile6(
        path3,
        JSON.stringify({ chainId: this.chainId.toString(), epoch: epoch.toString(), candidates })
      );
    } catch (err) {
      console.warn(`[epoch-committee] persist failed for epoch ${epoch}: ${err.message}`);
    }
  }
  /** #419: load the frozen committee for `epoch` from disk, or null if absent. */
  async loadFrozenCandidates(epoch) {
    try {
      const path3 = join6(this.dataDir, "epoch-committee", `${epoch}.json`);
      const raw = await readFile6(path3, "utf-8");
      const parsed = JSON.parse(raw);
      if (parsed?.chainId !== void 0 && parsed.chainId !== this.chainId.toString()) {
        return null;
      }
      if (parsed?.candidates?.core && parsed?.candidates?.reserve) {
        return parsed.candidates;
      }
      return null;
    } catch {
      return null;
    }
  }
  /**
   * Refetch the active validator set as ValidatorView objects for the
   * NEXT block's epoch. Cached per-epoch so we don't recompute every
   * block — the committee is intentionally stable across an epoch.
   */
  async fetchActiveValidatorViews() {
    try {
      const nextHeight = this.latestHeader.number + 1n;
      const epoch = nextHeight / EPOCH_BLOCKS;
      if (this.cachedCommittee && this.cachedCommittee.epoch === epoch) {
        return this.cachedCommittee.committee;
      }
      const committee = await this.computeCommitteeForEpoch(epoch);
      this.cachedCommittee = { epoch, committee };
      return committee;
    } catch (err) {
      console.error("[consensus] failed to load validator set:", err);
      return [];
    }
  }
  /** Start the automatic block production timer. */
  /**
   * Optional v3 (Aura + GRANDPA) init step. Callers who want v3 must
   * invoke this AFTER init() and BEFORE start(). No-op unless
   * USE_V3_ENGINE=1 is set in the environment.
   *
   * Requires two additional env vars when USE_V3_ENGINE=1:
   *   - ASENTUM_V3_SPEC: path to v3-genesis.json
   *   - ASENTUM_V3_DATA: dir for v3 persistent state (self-votes,
   *                      finality-journal). Defaults to
   *                      `${dataDir}/v3` when unset.
   *
   * The hooks here bridge v3's assemble/apply/sign/broadcast
   * primitives to the existing v2 buildBlock + applyExternalBlockTentative
   * + signVoteSafely + submitTransaction paths. v2 state machinery is
   * REUSED — only consensus is replaced. See notes/v3-wire-in-plan.md.
   */
  async initV3() {
    if (process.env.USE_V3_ENGINE !== "1") return;
    const specPath = process.env.ASENTUM_V3_SPEC;
    if (!specPath) {
      throw new Error("initV3: USE_V3_ENGINE=1 requires ASENTUM_V3_SPEC to be set");
    }
    const dataDir = process.env.ASENTUM_V3_DATA ?? join6(this.dataDir, "v3");
    const selfAddress = this.validatorKey?.address ?? null;
    const hooks = {
      assembleBlockBody: async (ctx) => {
        const parentHex = bytesToHex16(ctx.parentHash);
        const parentHeader = await this.loadV3ParentHeader(parentHex);
        const journal = await this.rebuildV3StateAt(parentHex);
        const pending = this.mempool.drain(MAX_TXS_PER_BLOCK);
        const blockNumberToBuild = parentHeader.number + 1n;
        const lastCommitBitmapForBlock = await this.buildV3LastCommitBitmap(parentHeader.number);
        const result = await buildBlock({
          store: journal,
          parentHeader,
          mempool: pending,
          proposer: ctx.proposer,
          timestamp: ctx.timestamp,
          gasLimit: BLOCK_GAS_LIMIT,
          baseFeePerGas: BASE_FEE_PER_GAS,
          execContext: { vm: this.vm, chainId: this.chainId },
          lastCommitBitmap: lastCommitBitmapForBlock,
          lastCommit: void 0,
          applySubsidiesHook: async (s) => {
            const prevMeta = await this.v3PrevMetaForParent(parentHeader);
            await this.applyBlockSubsidies(
              s,
              blockNumberToBuild,
              lastCommitBitmapForBlock,
              void 0,
              prevMeta
            );
          },
          applyCronHook: async (s, execCtx) => {
            if (!execCtx) return;
            await this.applyCronJobs(s, blockNumberToBuild, execCtx.blockTimestamp);
          }
        });
        if (process.env.ASENTUM_DUMP_PRODUCE_BLOCK && blockNumberToBuild <= BigInt(process.env.ASENTUM_DUMP_PRODUCE_BLOCK)) {
          await dumpDrift({
            dataDir: join6(this.dataDir, "produce"),
            stateStore: journal,
            block: result.block,
            expectedStateRoot: result.block.header.stateRoot,
            computedStateRoot: result.block.header.stateRoot,
            rebuiltReceipts: result.receipts ?? [],
            subsidyInputs: this.lastSubsidyInputs
          });
        }
        const bodyBytes = BlockSchema3.serialize(result.block);
        const auraHeader = this.buildV3AuraHeader(result.block.header, ctx);
        const auraHashHex = bytesToHex16(this.hashV3AuraHeader(auraHeader));
        this.v3PendingBlocks.set(auraHashHex, {
          block: result.block,
          receipts: result.receipts,
          parentHashHex: parentHex,
          height: result.block.header.number
        });
        this.putV3Overlay(this.v3StateOverlayCache, auraHashHex, journal);
        await this.maybeScheduleV3Reconfigure(result.block.header.number);
        console.log(`[v3-pending-set] path=produce hex=${auraHashHex.slice(0, 12)} h=${result.block.header.number} receipts=${Array.isArray(result.receipts) ? result.receipts.length : `NOT-ARRAY (${typeof result.receipts})`}`);
        return {
          stateRoot: result.block.header.stateRoot,
          transactionsRoot: result.block.header.transactionsRoot,
          receiptsRoot: result.block.header.receiptsRoot,
          gasUsed: result.block.header.gasUsed,
          gasLimit: result.block.header.gasLimit,
          baseFeePerGas: result.block.header.baseFeePerGas,
          bodyBytes
        };
      },
      applyBlock: async (block) => {
        if (block.bodyBytes.length === 0) return true;
        try {
          return await this.applyV3PeerBlock(block);
        } catch (err) {
          console.warn(`[v3-apply] rejected peer block: ${err.message}`);
          return false;
        }
      },
      signVote: async (body) => {
        if (!this.validatorKey) {
          throw new Error("initV3.signVote: no validator key on this node");
        }
        const msg = serializeVoteBodyForV3(body);
        return mlDsaSign(msg, this.validatorKey.keypair.secretKey);
      },
      broadcastBlock: (block) => {
        if (!this.v3SendBlock) return;
        this.v3SendBlock(encodeBlockWithBodyMessage(block.header, block.bodyBytes));
      },
      broadcastVote: (vote) => {
        if (!this.v3SendVote) return;
        if (process.env.ASENTUM_TEST_SUPPRESS_ANT_VOTES === "1") return;
        const wire = encodeVoteMessage(vote);
        const testDelayMs = Number(process.env.ASENTUM_TEST_VOTE_DELAY_MS ?? "0");
        if (Number.isFinite(testDelayMs) && testDelayMs > 0) {
          setTimeout(() => {
            this.v3SendVote?.(wire);
          }, testDelayMs);
          return;
        }
        this.v3SendVote(wire);
      },
      requestBlocks: (fromHeight, toHeight) => {
        if (!this.v3SendSyncRequest) return;
        const buf = new Uint8Array(16);
        const dv = new DataView(buf.buffer);
        dv.setBigUint64(0, fromHeight, false);
        dv.setBigUint64(8, toHeight, false);
        this.v3SendSyncRequest(buf);
      },
      submitSlashExhibit: (exhibit) => {
        if (this.role !== "producer") return;
        if (process.env.ASENTUM_V3_DISABLE_SLASHING === "1") {
          console.log(
            `[v3-slash] skipped (ASENTUM_V3_DISABLE_SLASHING=1) source=${exhibit.source}`
          );
          return;
        }
        try {
          const args = buildSlashCallArgs(exhibit);
          const tx = buildAndSignCall({
            senderKeypair: this.faucet.keypair,
            chainId: this.chainId,
            nonce: this.faucetNextNonce,
            to: SLASHING_CONTRACT_ADDRESS,
            value: 0n,
            gasLimit: 5000000n,
            method: args.method,
            args: [
              bytesToHex16(args.offender),
              bytesToHex16(args.exhibitA),
              bytesToHex16(args.exhibitB)
            ]
          });
          this.faucetNextNonce += 1n;
          const result = this.submitTransaction(tx);
          if (!result.accepted) {
            console.error(
              `[v3-slash] auto-submit rejected: ${result.reason} (source=${args.source})`
            );
          } else {
            console.log(
              `[v3-slash] submitted recordSlash tx source=${args.source} offender=${bytesToHex16(args.offender).slice(0, 14)}...`
            );
          }
        } catch (err) {
          console.error("[v3-slash] auto-submit failed:", err);
        }
      },
      onFinalize: (commit) => {
        console.log(
          `[v3-finalize] round=${commit.round} block=${commit.target.number}`
        );
        this.v3CommitChain = this.v3CommitChain.then(() => this.withApplyLock(() => this.commitV3Finalized(commit))).catch((err) => {
          console.error(`[v3-finalize] commit failed:`, err);
        });
      }
    };
    this.v3 = await bootV3({
      genesisSpecPath: specPath,
      dataDir,
      selfAddress,
      hooks
    });
    console.log(
      `[v3] engine boot complete: chainId=${this.v3.spec.chainId} committeeSize=${this.v3.spec.initialCommittee.length}`
    );
    await this.reconstructV3EngineOnBoot();
  }
  /**
   * Rebuild the v3 engine's committed head on boot (#414). Replays the
   * aura-header hash chain over the persisted v2 headers (which carry
   * `slot`) — hashing only, no state execution — to derive the committed
   * head's AuraBlockHeader, then hands it to engine.reconstruct() so the
   * fork-choice + finality store resume at the committed height. Cheap even
   * for long chains; runs once per boot. No-op on a fresh genesis chain.
   */
  async reconstructV3EngineOnBoot() {
    if (!this.v3) return;
    const headNumber = this.latestHeader.number;
    if (headNumber === 0n) return;
    const { buildGenesisHeader: buildGenesisHeader2 } = await Promise.resolve().then(() => (init_genesis_spec(), genesis_spec_exports));
    const { slotForTime: slotForTime2 } = await Promise.resolve().then(() => (init_slot(), slot_exports));
    const slotCfg = {
      genesisSlot0UnixMs: this.v3.spec.slot0StartUnixMs,
      slotDurationMs: this.v3.spec.slotDurationMs
    };
    let parentAuraHash = this.hashV3AuraHeader(buildGenesisHeader2(this.v3.spec));
    let headAura = null;
    for (let n = 1n; n <= headNumber; n += 1n) {
      const block = await this.blockStore.getBlockByNumber(n);
      if (!block) {
        console.error(
          `[v3-reconstruct] block ${n} missing from store (head=${headNumber}); skipping reconstruction \u2014 engine will cold-start (slow catch-up)`
        );
        return;
      }
      const v2h = block.header;
      const aura = {
        parentHash: parentAuraHash,
        stateRoot: v2h.stateRoot,
        transactionsRoot: v2h.transactionsRoot,
        receiptsRoot: v2h.receiptsRoot,
        number: v2h.number,
        slot: slotForTime2(slotCfg, Number(v2h.timestamp)),
        timestamp: v2h.timestamp,
        gasLimit: v2h.gasLimit,
        gasUsed: v2h.gasUsed,
        baseFeePerGas: v2h.baseFeePerGas,
        proposer: v2h.proposer
      };
      const thisAuraHash = this.hashV3AuraHeader(aura);
      const auraHashHex = bytesToHex16(thisAuraHash);
      this.v3FinalizedByAura.set(auraHashHex, v2h);
      this.recordV3AuraHashForHeight(v2h.number, thisAuraHash);
      if (this.v3FinalizedByAura.size > _AsentumNode.V3_FINALIZED_BY_AURA_MAX) {
        const oldest = this.v3FinalizedByAura.keys().next().value;
        if (oldest !== void 0) this.v3FinalizedByAura.delete(oldest);
      }
      parentAuraHash = thisAuraHash;
      headAura = aura;
    }
    if (headAura) {
      this.v3.engine.reconstruct(headAura);
      console.log(`[v3-reconstruct] boot: replayed ${headNumber} headers; engine re-rooted at committed head ${headNumber}`);
      if (process.env.ASENTUM_V3_DYNAMIC_COMMITTEE === "1") {
        try {
          const bootEpoch = (headNumber + 1n) / EPOCH_BLOCKS;
          if (bootEpoch >= 1n) {
            const views = await this.computeCommitteeForEpoch(bootEpoch, { deterministic: true });
            if (views.length > 0) {
              const committee = views.map((v) => hexToBytes11(v.address));
              const stake = views.map((v) => BigInt(v.stake));
              this.v3.engine.reconfigureCommittee(headNumber + 1n, committee, stake);
              this.v3LastScheduledEpoch = bootEpoch;
              console.log(
                `[v3-reconfigure] boot: resumed mid-epoch ${bootEpoch}, committee=${committee.length} effective from h=${headNumber + 1n}`
              );
            }
          }
        } catch (err) {
          console.log(`[v3-reconfigure] boot reconfigure failed (continuing with genesis set): ${String(err)}`);
        }
      }
      await this.maybeScheduleV3Reconfigure(headNumber);
    }
  }
  /**
   * Load the v2 BlockHeader corresponding to a v3 parent hash. If the
   * parent is the genesis (v3LastFinalizedHeader === null), returns
   * the genesis-derived header. Otherwise reads from blockStore.
   * Throws if the parent isn't findable — v3 forkChoice must not
   * return an orphaned head.
   */
  /**
   * Resolve the v2 header for a given block NUMBER that has been APPLIED —
   * whether finalized (in blockStore) or still pending GRANDPA finality (in
   * v3PendingBlocks). Returns null when height N has not been applied by this
   * node yet. Used by getEpochCandidatesFiltered to decide whether an epoch's
   * seed block is available, so the committee is only ever computed (and frozen)
   * against a seed-anchored, deterministic state — never a transient replay
   * head. Note v3PendingBlocks is keyed by Aura hash, so this scans it by the
   * `height` field; the pending depth is small (a few finality lags), so the
   * scan is cheap and only runs on cache/freeze misses (once per epoch).
   */
  async resolveAppliedHeaderByNumber(blockNumber) {
    const finalized = await this.blockStore.getBlockByNumber(blockNumber);
    if (finalized) return finalized.header;
    for (const entry of this.v3PendingBlocks.values()) {
      if (entry.height === blockNumber) return entry.block.header;
    }
    return null;
  }
  /**
   * Resolve a READ-ONLY state store + timestamp representing on-chain state
   * exactly AS-OF block `seedBlockNumber`, for deterministic committee/tombstone
   * queries. Returns:
   *   - finalized branch: `this.stateStore` (already reflects the seed) when the
   *     seed block is at or below the finalized tip — the common live-cluster
   *     case, zero extra work.
   *   - pending branch: a journal rebuilt at the seed block's Aura hash when the
   *     seed is applied but not yet finalized — the cold-replayer case, where
   *     this.stateStore still lags (often at genesis) so reading it would miss
   *     every mid-chain bond. The rebuilt journal contains the seed's exact
   *     post-state, identical to what the live cluster had finalized.
   *   - null: the seed block is not applied anywhere yet (genuinely unresolvable).
   * Genesis (seedBlockNumber 0) always resolves to this.stateStore.
   */
  async resolveSeedReadState(seedBlockNumber) {
    if (seedBlockNumber === 0n) {
      return { store: this.stateStore, timestamp: this.latestHeader.timestamp };
    }
    const finalizedHead = this.v3LastFinalizedHeader?.number ?? this.latestHeader.number;
    const finalizedSeed = await this.blockStore.getBlockByNumber(seedBlockNumber);
    if (finalizedSeed && seedBlockNumber <= finalizedHead) {
      return { store: this.stateStore, timestamp: finalizedSeed.header.timestamp };
    }
    for (const [auraHex, entry] of this.v3PendingBlocks.entries()) {
      if (entry.height === seedBlockNumber) {
        const journal = await this.rebuildV3StakingStateAt(auraHex);
        return {
          store: journal,
          timestamp: entry.block.header.timestamp
        };
      }
    }
    if (finalizedSeed) {
      return { store: this.stateStore, timestamp: finalizedSeed.header.timestamp };
    }
    return null;
  }
  async loadV3ParentHeader(parentHex) {
    const pending = this.v3PendingBlocks.get(parentHex);
    if (pending) return pending.block.header;
    const finalized = this.v3FinalizedByAura.get(parentHex);
    if (finalized) return finalized;
    if (this.v3LastFinalizedHeader) return this.v3LastFinalizedHeader;
    return this.latestHeader;
  }
  /**
   * Rebuild a journal representing state at the block identified by
   * `targetHashHex`. Walks pending blocks from last-finalized up to
   * target, replaying each block's transactions against a fresh
   * journal. O(pending_depth × txs_per_block). Typical depth is ~3
   * at 3s slot / 9s finality lag.
   */
  /**
   * Rebuild a journal at `targetHashHex` for READING STAKING/SLASHING state
   * only. Replays cron + transactions (which is everything that mutates bonds,
   * epoch-entries, and tombstones) but DELIBERATELY SKIPS applyBlockSubsidies.
   * Subsidies only credit reward balances — they never change committee
   * membership — and, critically, applyBlockSubsidies re-enters
   * getEpochCandidatesFiltered (via computeWarmInactiveForReward), which is the
   * very function that calls this one; running subsidies here would recurse.
   * The resulting store is thus a correct, side-effect-bounded view of the
   * staking set as-of the target block. Not for state-root verification.
   */
  async rebuildV3StakingStateAt(targetHashHex) {
    const { chain, seed } = this.walkV3PendingWithCache(
      targetHashHex,
      this.v3StakingOverlayCache
    );
    const journal = new JournaledStateStore(this.stateStore);
    if (seed) journal.seedOverlay(seed);
    for (const hex2 of chain) {
      const entry = this.v3PendingBlocks.get(hex2);
      const ctx = {
        vm: this.vm,
        chainId: this.chainId,
        blockNumber: entry.block.header.number,
        blockTimestamp: entry.block.header.timestamp
      };
      await this.applyCronJobs(
        journal,
        entry.block.header.number,
        entry.block.header.timestamp
      );
      for (const tx of entry.block.transactions) {
        await applyTransaction(journal, tx, ctx);
      }
      this.putV3Overlay(this.v3StakingOverlayCache, hex2, journal);
    }
    return journal;
  }
  /**
   * Shared walk for the two rebuilders: collect the pending suffix that still
   * needs replaying (oldest→newest), stopping early at the nearest ancestor
   * with a cached overlay. In steady state the target's direct parent is
   * cached (it was the previous target), so `chain` is 0-1 blocks long no
   * matter how deep the pending chain is — that's the whole spiral fix.
   */
  walkV3PendingWithCache(targetHashHex, cache) {
    const cacheOn = process.env.ASENTUM_V3_OVERLAY_CACHE !== "0";
    const chain = [];
    let cursor = targetHashHex;
    while (cursor && this.v3PendingBlocks.has(cursor)) {
      if (cacheOn) {
        const cached = cache.get(cursor);
        if (cached) return { chain, seed: cached };
      }
      chain.unshift(cursor);
      const entry = this.v3PendingBlocks.get(cursor);
      cursor = entry.parentHashHex;
    }
    return { chain, seed: null };
  }
  /** Memoize a just-replayed pending block's overlay; evict oldest past cap. */
  putV3Overlay(cache, hex2, journal) {
    if (process.env.ASENTUM_V3_OVERLAY_CACHE === "0") return;
    cache.set(hex2, journal.snapshotOverlay());
    while (cache.size > _AsentumNode.V3_OVERLAY_CACHE_MAX) {
      const oldest = cache.keys().next().value;
      if (oldest === void 0) break;
      cache.delete(oldest);
    }
  }
  async rebuildV3StateAt(targetHashHex) {
    const t0 = performance.now();
    const { chain, seed } = this.walkV3PendingWithCache(
      targetHashHex,
      this.v3StateOverlayCache
    );
    const journal = new JournaledStateStore(this.stateStore);
    if (seed) journal.seedOverlay(seed);
    for (const hex2 of chain) {
      const entry = this.v3PendingBlocks.get(hex2);
      const ctx = {
        vm: this.vm,
        chainId: this.chainId,
        blockNumber: entry.block.header.number,
        blockTimestamp: entry.block.header.timestamp
      };
      await this.applyCronJobs(
        journal,
        entry.block.header.number,
        entry.block.header.timestamp
      );
      for (const tx of entry.block.transactions) {
        await applyTransaction(journal, tx, ctx);
      }
      const parentHeader = await this.loadV3ParentHeader(entry.parentHashHex);
      const prevMeta = await this.v3PrevMetaForParent(parentHeader);
      await this.applyBlockSubsidies(
        journal,
        entry.block.header.number,
        // #417: reward from the bitmap the producer embedded in THIS block's
        // header (the single deterministic value that its stateRoot was
        // computed with), never a local re-derivation. Producer, cluster
        // appliers, and cold followers all read the identical field -> byte
        // identical reward -> byte identical state root.
        entry.block.header.lastCommitBitmap,
        void 0,
        prevMeta
      );
      this.putV3Overlay(this.v3StateOverlayCache, hex2, journal);
    }
    const ms = performance.now() - t0;
    if (process.env.ASENTUM_V3_REBUILD_TIMING === "1" || ms > 250) {
      console.log(
        `[v3-rebuild] pending=${this.v3PendingBlocks.size} replayed=${chain.length} seeded=${seed ? 1 : 0} ms=${ms.toFixed(1)}`
      );
    }
    return journal;
  }
  /**
   * Target B fix (#408 non-Aura production hole): verify that `proposer` is the
   * deterministic Aura leader for `(number, slot)`. The leader is
   * committee[slot % committee.length] over the SAME deterministic committee the
   * reward path uses (frozen, no local dormancy), so every node computes the
   * identical expected proposer for a given height+slot. Returns true when the
   * block's proposer matches, false otherwise.
   *
   * Why this closes the fork: the Aura producer binds leadership to the
   * wall-clock SLOT but builds at height = fork-choice-head + 1. Slot and height
   * are decoupled (slots advance every 3s regardless of block production), so a
   * node whose head LAGS under cross-DC latency can find itself the leader for
   * its current slot while its target height was ALREADY produced by the real
   * slot-leader at an earlier slot → two competing blocks at the same height,
   * symmetric state-root divergence, [v3-verify] REJECT, minority wedge. The
   * receive path previously accepted any proposer (only the state root was
   * checked). Enforcing proposer == deterministic-slot-leader makes every node
   * deterministically reject the off-schedule block, so it never enters the
   * fork tree, never gets voted, and cannot compete. A dead real leader simply
   * yields an empty slot and Aura advances to the next slot's leader — that is
   * deterministic and fine; only a NON-leader silently producing is the bug.
   */
  async isDeterministicAuraLeader(header) {
    if (process.env.ASENTUM_V3_DYNAMIC_COMMITTEE !== "1") return true;
    if (process.env.ASENTUM_V3_LEADER_GUARD === "0") return true;
    if (header.number === 0n) return true;
    const epoch = header.number / EPOCH_BLOCKS;
    let committee;
    try {
      const raw = await this.computeCommitteeForEpoch(epoch, { deterministic: true });
      const filterLeaders = V3_LEADER_FILTER_FROM_EPOCH != null && header.number / EPOCH_BLOCKS >= V3_LEADER_FILTER_FROM_EPOCH;
      committee = filterLeaders ? await this.applyV3Dormancy(epoch, raw, {
        selfKeep: false,
        // #455: MUST match the producer's leader-committee liveness signal
        // (maybeScheduleV3Reconfigure) or verify computes a different Aura
        // leader than the producer → off-schedule-proposer REJECT → wedge.
        byProposer: process.env.ASENTUM_V3_LEADER_FILTER_BY_PROPOSER !== "0"
      }) : raw;
    } catch {
      return true;
    }
    if (committee.length === 0) return true;
    const idx = Number(header.slot % BigInt(committee.length));
    const expectedHex = committee[idx].address.toLowerCase();
    const actualHex = bytesToHex16(header.proposer).toLowerCase();
    return expectedHex === actualHex;
  }
  /**
   * Fork-tolerant peer block apply. Verifies parent is in our
   * pending chain or is the finalized tip, replays the block's
   * transactions against the parent's rebuilt state, verifies the
   * stateRoot matches the peer's declared root, stores the pending
   * entry keyed by AuraBlockHeader hash. Journal is NOT committed;
   * commitV3Finalized does that on GRANDPA finalize.
   */
  async applyV3PeerBlock(auraBlock) {
    const v2Block = BlockSchema3.deserialize(new Uint8Array(auraBlock.bodyBytes));
    const auraHashHex = bytesToHex16(this.hashV3AuraHeader(auraBlock.header));
    if (this.v3PendingBlocks.has(auraHashHex)) return true;
    if (!await this.isDeterministicAuraLeader(auraBlock.header)) {
      console.error(
        `[v3-verify] REJECT off-schedule proposer h=${auraBlock.header.number} slot=${auraBlock.header.slot} hex=${auraHashHex.slice(0, 12)} proposer=${bytesToHex16(auraBlock.header.proposer).slice(2, 10)} is not the slot leader`
      );
      return false;
    }
    const parentHex = bytesToHex16(auraBlock.header.parentHash);
    const journal = await this.rebuildV3StateAt(parentHex);
    const ctx = {
      vm: this.vm,
      chainId: this.chainId,
      blockNumber: v2Block.header.number,
      blockTimestamp: v2Block.header.timestamp
    };
    await this.applyCronJobs(
      journal,
      v2Block.header.number,
      v2Block.header.timestamp
    );
    const receipts = [];
    for (let txIndex = 0; txIndex < v2Block.transactions.length; txIndex++) {
      const tx = v2Block.transactions[txIndex];
      if (!tx) continue;
      const r = await applyTransaction(journal, tx, ctx);
      if (!r.ok) {
        throw new Error(`applyV3PeerBlock: tx rejected: ${r.reason}`);
      }
      const txHash3 = blake318(SignedTransactionSchema5.serialize(tx));
      receipts.push(
        buildReceipt(txHash3, v2Block.header.number, txIndex, {
          kind: r.kind,
          senderAddress: r.senderAddress,
          recipientAddress: r.recipientAddress,
          gasUsed: r.gasUsed,
          events: r.events,
          returnValue: r.returnValue,
          success: r.status,
          errorReason: r.status === 0 ? r.reason : void 0
        })
      );
    }
    const parentHeader = await this.loadV3ParentHeader(parentHex);
    const prevMeta = await this.v3PrevMetaForParent(parentHeader);
    await this.applyBlockSubsidies(
      journal,
      v2Block.header.number,
      v2Block.header.lastCommitBitmap,
      v2Block.lastCommit,
      prevMeta
    );
    const computedRoot = await journal.stateRoot();
    if (!bytesEqual13(computedRoot, v2Block.header.stateRoot)) {
      console.error(
        `[v3-verify] REJECT unverifiable peer block h=${v2Block.header.number} hex=${auraHashHex.slice(0, 12)} proposer=${bytesToHex16(v2Block.header.proposer).slice(2, 10)}: computed ${bytesToHex16(computedRoot).slice(0, 14)}\u2026 != header ${bytesToHex16(v2Block.header.stateRoot).slice(0, 14)}\u2026`
      );
      if (process.env.ASENTUM_DEBUG_APPLY_DUMP === "1") {
        await dumpDrift({
          dataDir: join6(this.dataDir, "reject"),
          stateStore: journal,
          block: v2Block,
          expectedStateRoot: v2Block.header.stateRoot,
          computedStateRoot: computedRoot,
          rebuiltReceipts: receipts,
          subsidyInputs: this.lastSubsidyInputs
        });
      }
      return false;
    }
    this.v3PendingBlocks.set(auraHashHex, {
      block: v2Block,
      receipts,
      parentHashHex: parentHex,
      height: v2Block.header.number
    });
    this.putV3Overlay(this.v3StateOverlayCache, auraHashHex, journal);
    await this.maybeScheduleV3Reconfigure(v2Block.header.number);
    console.log(`[v3-pending-set] path=peer hex=${auraHashHex.slice(0, 12)} h=${v2Block.header.number} receipts=${Array.isArray(receipts) ? receipts.length : `NOT-ARRAY (${typeof receipts})`}`);
    return true;
  }
  /**
   * Walk pending chain from finalized target back to last-finalized,
   * commit each pending block in ascending order, prune orphans.
   * Advances this.latestHeader + this.v3LastFinalizedHeader.
   */
  async commitV3Finalized(commit) {
    const targetHex = bytesToHex16(commit.target.hash);
    const chain = [];
    let cursor = targetHex;
    while (cursor && this.v3PendingBlocks.has(cursor)) {
      chain.unshift(cursor);
      const entry = this.v3PendingBlocks.get(cursor);
      cursor = entry.parentHashHex;
    }
    if (chain.length === 0) {
      if (this.v3LastFinalizedTargetHex !== null && bytesToHex16(commit.target.hash) === this.v3LastFinalizedTargetHex) {
        this.v3LastFinalizedCert = commit;
      }
      return;
    }
    const committedHeights = [];
    for (const hex2 of chain) {
      const entry = this.v3PendingBlocks.get(hex2);
      if (!entry || !entry.receipts || !entry.block) {
        console.error(`[v3-commit-diag] BROKEN entry at hex=${hex2.slice(0, 12)} entry=${JSON.stringify({ hasEntry: !!entry, hasBlock: !!entry?.block, hasReceipts: !!entry?.receipts, receiptsType: typeof entry?.receipts, receiptsIsArray: Array.isArray(entry?.receipts), parentHex: entry?.parentHashHex?.slice(0, 12), height: entry?.height?.toString() })}`);
        throw new Error(`v3-commit: pending entry has undefined receipts at hex=${hex2.slice(0, 12)}`);
      }
      const journal = new JournaledStateStore(this.stateStore);
      const ctx = {
        vm: this.vm,
        chainId: this.chainId,
        blockNumber: entry.block.header.number,
        blockTimestamp: entry.block.header.timestamp
      };
      await this.applyCronJobs(
        journal,
        entry.block.header.number,
        entry.block.header.timestamp
      );
      for (const tx of entry.block.transactions) {
        await applyTransaction(journal, tx, ctx);
      }
      const parentHeader = await this.loadV3ParentHeader(entry.parentHashHex);
      const prevMeta = await this.v3PrevMetaForParent(parentHeader);
      await this.applyBlockSubsidies(
        journal,
        entry.block.header.number,
        // #417: reward from the header's embedded bitmap (single deterministic
        // source used by producer + all appliers + followers), never a local
        // re-derivation.
        entry.block.header.lastCommitBitmap,
        void 0,
        prevMeta
      );
      if (process.env.ASENTUM_DUMP_OK_BLOCK && entry.block.header.number <= BigInt(process.env.ASENTUM_DUMP_OK_BLOCK)) {
        await dumpDrift({
          dataDir: this.dataDir,
          stateStore: journal,
          block: entry.block,
          expectedStateRoot: entry.block.header.stateRoot,
          computedStateRoot: entry.block.header.stateRoot,
          rebuiltReceipts: entry.receipts ?? [],
          subsidyInputs: this.lastSubsidyInputs
        });
      }
      const committedRoot = await journal.stateRoot();
      if (!bytesEqual13(committedRoot, entry.block.header.stateRoot)) {
        console.error(
          `[v3-commit] REJECT divergent commit h=${entry.block.header.number} hex=${hex2.slice(0, 12)}: computed ${bytesToHex16(committedRoot).slice(0, 14)}\u2026 != header ${bytesToHex16(entry.block.header.stateRoot).slice(0, 14)}\u2026`
        );
        throw new Error(
          `v3-commit: commit-path state root diverges from header at h=${entry.block.header.number}`
        );
      }
      await journal.commit();
      await this.blockStore.putBlock(entry.block);
      if (!entry.receipts || !Array.isArray(entry.receipts)) {
        const currentEntry = this.v3PendingBlocks.get(hex2);
        console.error(`[v3-commit-diag2] receipts VANISHED at hex=${hex2.slice(0, 12)} h=${entry.block.header.number} local.receipts=${typeof entry.receipts} map.entry=${currentEntry === entry ? "same-ref" : "different-ref"} map.receipts=${typeof currentEntry?.receipts}`);
        throw new Error(`v3-commit: receipts became undefined between check and putReceipts at h=${entry.block.header.number}`);
      }
      await this.blockStore.putReceipts(entry.block.header.number, entry.receipts);
      this.latestHeader = entry.block.header;
      this.v3LastFinalizedHeader = entry.block.header;
      this.v3FinalizedByAura.set(hex2, entry.block.header);
      this.recordV3AuraHashForHeight(entry.block.header.number, hexToBytes11(hex2));
      const blockHash = blake318(BlockHeaderSchema4.serialize(entry.block.header));
      this.prevBlockMeta = await this.captureCurrentBlockMeta(entry.block, blockHash);
      committedHeights.push(entry.block.header.number);
    }
    this.v3LastFinalizedCert = commit;
    this.v3LastFinalizedTargetHex = bytesToHex16(commit.target.hash);
    const finalizedHeight = this.v3LastFinalizedHeader.number;
    for (const [hex2, entry] of Array.from(this.v3PendingBlocks.entries())) {
      if (entry.height <= finalizedHeight) {
        this.v3PendingBlocks.delete(hex2);
        this.v3StateOverlayCache.delete(hex2);
        this.v3StakingOverlayCache.delete(hex2);
      }
    }
    while (this.v3FinalizedByAura.size > _AsentumNode.V3_FINALIZED_BY_AURA_MAX) {
      const oldest = this.v3FinalizedByAura.keys().next().value;
      if (oldest === void 0) break;
      this.v3FinalizedByAura.delete(oldest);
    }
    for (const h of committedHeights) {
      await this.maybeSubmitEpochEntry(h).catch((err) => {
        console.error("[epoch-entry] v3 auto-submit hook failed (non-fatal):", err);
      });
    }
    void this.mempool.evictStale(async (addr) => {
      const account = await this.stateStore.getAccount(
        Uint8Array.from(Buffer.from(addr.replace("0x", ""), "hex"))
      );
      return account?.nonce ?? 0n;
    }).then((evicted) => {
      if (evicted > 0) console.log(`[mempool] v3-commit evicted ${evicted} stale txs`);
    }).catch(() => {
    });
  }
  /**
   * Convert a v2 BlockHeader + AssembleContext into the
   * AuraBlockHeader shape v3's fork-choice hashes on. The two
   * headers share most fields; slot comes from ctx (v2 has no slot).
   */
  buildV3AuraHeader(v2Header, ctx) {
    return {
      parentHash: ctx.parentHash,
      stateRoot: v2Header.stateRoot,
      transactionsRoot: v2Header.transactionsRoot,
      receiptsRoot: v2Header.receiptsRoot,
      number: v2Header.number,
      slot: ctx.slot,
      timestamp: v2Header.timestamp,
      gasLimit: v2Header.gasLimit,
      gasUsed: v2Header.gasUsed,
      baseFeePerGas: v2Header.baseFeePerGas,
      proposer: v2Header.proposer
    };
  }
  /** blake3(AuraBlockHeaderSchema.serialize(header)). Cached inline. */
  hashV3AuraHeader(header) {
    return blake318(AuraBlockHeaderSchema4.serialize(header));
  }
  /**
   * Bridge v3's GRANDPA SignedCommit bitmap to v2's 16-byte
   * lastCommitBitmap format. Assumes v3 committee order matches
   * v2 prevBlockMeta.committeeAddresses order (cutover invariant:
   * BAKED_GENESIS_VALIDATORS === spec.initialCommittee, same
   * order). Returns all-zero bitmap on first block (no finalized
   * cert yet).
   *
   * See notes/v3-rewards-bitmap-bridge-design.md for the full
   * design + determinism analysis.
   */
  async buildV3LastCommitBitmap(parentNumber) {
    const bitmap = new Uint8Array(16);
    const latestCert = this.v3LastFinalizedCert;
    if (!latestCert) return bitmap;
    let cert = this.v3?.engine.certForConsumption(latestCert) ?? latestCert;
    const parentEpoch = parentNumber / EPOCH_BLOCKS;
    const certEpoch = cert.round / EPOCH_BLOCKS;
    if (certEpoch !== parentEpoch) {
      let sameCommittee = false;
      try {
        const certList = await this.computeCommitteeForEpoch(certEpoch, { deterministic: true });
        const parentList = await this.computeCommitteeForEpoch(parentEpoch, {
          deterministic: true
        });
        sameCommittee = certList.length === parentList.length && certList.every(
          (v, i) => v.address.toLowerCase() === parentList[i].address.toLowerCase()
        );
      } catch {
      }
      if (!sameCommittee) {
        let replacement = null;
        if (latestCert.round / EPOCH_BLOCKS === parentEpoch) {
          const epochStart = parentEpoch * EPOCH_BLOCKS;
          const cap = latestCert.round - epochStart > 128n ? epochStart + 128n : latestCert.round;
          for (let r = epochStart; r <= cap; r++) {
            const c = this.v3?.engine.commitAt(r) ?? null;
            if (c) {
              replacement = c;
              break;
            }
          }
          replacement = replacement ?? latestCert;
        }
        if (!replacement) return bitmap;
        cert = replacement;
      }
    }
    this.v3?.engine.recordCertConsumption(cert.round, cert.bitmap);
    const bitLen = cert.bitmap.length * 8;
    for (let i = 0; i < Math.min(bitLen, 128); i++) {
      const srcByteIdx = i >> 3;
      const srcBitIdx = i & 7;
      const srcByte = cert.bitmap[srcByteIdx];
      if (srcByte === void 0) continue;
      if (!(srcByte >> srcBitIdx & 1)) continue;
      const dstByteIdx = i >> 3;
      const dstBitIdx = 7 - (i & 7);
      bitmap[dstByteIdx] = (bitmap[dstByteIdx] ?? 0) | 1 << dstBitIdx;
    }
    return bitmap;
  }
  /**
   * Inject the network transport into v3 broadcast hooks after ANT
   * swarm is constructed. Called by bin/run.ts once antSwarm.start()
   * completes. Idempotent — later calls replace the callbacks.
   * No-op when v3 is not engaged.
   */
  setV3BroadcastCallbacks(cb) {
    if (!this.v3) return;
    this.v3SendBlock = cb.block;
    this.v3SendVote = cb.vote;
    this.v3SendSyncRequest = cb.syncRequest ?? null;
    console.log("[v3] broadcast callbacks wired");
  }
  /**
   * #416: authenticate an inbound GRANDPA vote before it reaches the engine.
   * The signature covers `serializeVoteBodyForV3(body)` (identical to the
   * signing path in initV3's signVote hook) under the voter's ML-DSA-65
   * committee pubkey. Returns:
   *   - true  when verification is DISABLED (env flag off — default), OR the
   *           voter's pubkey is unknown (fail OPEN — never drop a vote we
   *           cannot check, so this is never worse for liveness than the
   *           pre-#416 accept-everything path), OR the signature is valid.
   *   - false ONLY when we HAVE the voter's committee pubkey and the
   *           signature is provably invalid (a forged / tampered vote).
   * Gated behind ASENTUM_V3_VERIFY_VOTES=1 so it can be enabled devnet-first
   * and disabled instantly if a serialization mismatch ever surfaced.
   */
  verifyPeerVoteV3(vote) {
    if (process.env.ASENTUM_V3_VERIFY_VOTES !== "1") return true;
    const addr = bytesToHex16(vote.body.voter).toLowerCase();
    const pubkey = this.v3CommitteePubkeys.get(addr);
    if (!pubkey) return true;
    try {
      const msg = serializeVoteBodyForV3(vote.body);
      const ok = mlDsaVerify(msg, vote.signature, pubkey);
      if (!ok) {
        console.warn(
          `[v3-vote-verify] REJECT forged vote: voter=${addr.slice(0, 10)} round=${vote.body.round} kind=${vote.body.kind} h=${vote.body.target.number}`
        );
      }
      return ok;
    } catch (err) {
      console.warn(
        `[v3-vote-verify] REJECT unverifiable vote from ${addr.slice(0, 10)}: ${err.message}`
      );
      return false;
    }
  }
  /**
   * Expose the v3 engine so bin/run.ts can route inbound ANT BLOCK +
   * VOTE frames into onPeerBlock/onPeerVote. Returns null when v3 is
   * not engaged.
   */
  getV3Engine() {
    return this.v3?.engine ?? null;
  }
  start() {
    if (this.running) return;
    this.running = true;
    if (this.v3) {
      this.v3.engine.start();
      console.log("[v3] engine.start() invoked");
      this.startV3PullMode();
      const v3 = this.v3;
      this.v3DivergenceProbe = setInterval(() => {
        try {
          const forkHead = v3.engine.getHead();
          const committed = this.latestHeader.number;
          if (forkHead.number > committed + 3n) {
            console.log(
              `[v3-divergence] fork=${forkHead.number} committed=${committed} lag=${forkHead.number - committed} (GRANDPA behind Aura \u2014 chain stuck at RPC layer)`
            );
          }
        } catch {
        }
      }, 1e4);
    }
    if (!this.manualBlockProduction) {
      if (!isLPCEngine() && !this.v3) {
        this.producerInterval = setInterval(() => {
          void this.produceBlockSafe().catch((err) => {
            console.error("[node] block production failed:", err);
          });
        }, this.blockTimeMs);
      }
    }
  }
  /** Stop the producer and close all underlying stores. */
  async stop() {
    this.running = false;
    if (this.producerInterval) {
      clearInterval(this.producerInterval);
      this.producerInterval = void 0;
    }
    if (this.v3DivergenceProbe) {
      clearInterval(this.v3DivergenceProbe);
      this.v3DivergenceProbe = void 0;
    }
    if (this.v3) {
      this.v3.engine.stop();
    }
    this.stopV3PullMode();
    this.clearProposalTimeout();
    await this.stateStore.close();
    await this.blockStore.close();
  }
  /**
   * Produce a block immediately, regardless of the timer.
   *
   * Returns `null` if this node is NOT the scheduled proposer for the
   * current `(height, round)` — single-validator federation never hits
   * this branch (the only validator is always the proposer), but in
   * multi-validator BFT only one node should build per round and the
   * others should wait for the proposer's broadcast.
   */
  async produceBlockNow() {
    return this.produceBlockSafe();
  }
  async produceBlockSafe() {
    if (this.v3) return null;
    if (this.blockProductionInFlight) {
      return this.blockProductionInFlight;
    }
    const p = this.withApplyLock(() => this.produceBlockInner());
    this.blockProductionInFlight = p;
    try {
      return await p;
    } finally {
      this.blockProductionInFlight = null;
    }
  }
  async produceBlockInner() {
    const h = this.latestHeader.number + 1n;
    const r = this.consensusEngine.getRound();
    const proposer = this.consensusEngine.currentProposer();
    const selfHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    if (proposer) {
      if (proposer.address.toLowerCase() !== selfHex) {
        const skipKey = `${h}:${r}`;
        if (this.lastSkipLogKey !== skipKey) {
          this.lastSkipLogKey = skipKey;
          console.log(`[producer] skip h=${h} r=${r}: scheduled=${proposer.address.slice(0, 14)}\u2026 self=${selfHex.slice(0, 14)}\u2026`);
        }
        return null;
      }
      console.log(`[producer] my turn h=${h} r=${r} self=${selfHex.slice(0, 14)}\u2026`);
    } else {
      console.log(`[producer] no proposer for h=${h} r=${r} (validators=${this.consensusEngine.getValidators().length}); attempting build anyway`);
    }
    this.clearProposalTimeout();
    const currentHeight = this.latestHeader.number + 1n;
    if (process.env.ASENTUM_POL_ENABLED === "1") {
      const polkas = this.consensusEngine.getPolkasForCurrentHeight();
      if (polkas.length > 0) {
        for (let i = polkas.length - 1; i >= 0; i--) {
          const polka = polkas[i];
          if (!polka) continue;
          const polkadBlock = this.heightBlockCache.get(polka.hashHex);
          if (polkadBlock && polkadBlock.header.number === currentHeight) {
            const r_current = this.consensusEngine.getRound();
            console.log(
              `[pol] re-broadcasting polka'd block ${polka.hashHex.slice(0, 14)}\u2026 (polRound=${polka.round}) at h=${currentHeight} r=${r_current}`
            );
            this.cacheProposal(
              BlockSchema3.serialize(polkadBlock),
              polkadBlock.header.number,
              r_current,
              polkadBlock.header.proposer
            );
            void this.emitProposalBroadcast(polkadBlock, r_current, polka.round).catch((err) => {
              console.log(`[pol-broadcast] failed: ${String(err)}`);
            });
            this.armProposalTimeoutIfNotProposer();
            return null;
          }
        }
      }
    }
    let block;
    let receipts;
    let producerJournal = null;
    if (this.cachedProposal && this.cachedProposal.height === currentHeight) {
      block = this.cachedProposal.block;
      receipts = this.cachedProposal.receipts;
      producerJournal = this.cachedProposal.journal;
    } else {
      const tStart = Date.now();
      producerJournal = new JournaledStateStore(this.stateStore);
      const storeForBuild = producerJournal;
      const pending = this.mempool.drain(MAX_TXS_PER_BLOCK);
      const graceMs = Number(process.env.ASENTUM_BITMAP_GRACE_MS ?? 0);
      if (graceMs > 0) {
        const lastCommitMs = this.consensusEngine.getLastCommittedAtWallMs();
        if (lastCommitMs > 0) {
          const elapsed = Date.now() - lastCommitMs;
          const remaining = graceMs - elapsed;
          if (remaining > 0) {
            await new Promise((resolve) => setTimeout(resolve, remaining));
          }
        }
      }
      const lastCommitBitmap = this.buildBitmapForNextBlock();
      const lastCommit = this.buildLastCommitForNextBlock();
      const blockNumberToBuild = this.latestHeader.number + 1n;
      const producePrevMeta = await this.v3PrevMetaForParent(this.latestHeader);
      const tDrainEnd = Date.now();
      let tCronEnd = tDrainEnd;
      let tSubsidiesEnd = tDrainEnd;
      const result = await buildBlock({
        store: storeForBuild,
        parentHeader: this.latestHeader,
        mempool: pending,
        proposer: this.validatorKey.address,
        timestamp: BigInt(Math.floor(Date.now() / 1e3)),
        gasLimit: BLOCK_GAS_LIMIT,
        baseFeePerGas: BASE_FEE_PER_GAS,
        execContext: { vm: this.vm, chainId: this.chainId },
        lastCommitBitmap,
        lastCommit,
        applySubsidiesHook: async (s) => {
          const t0 = Date.now();
          await this.applyBlockSubsidies(s, blockNumberToBuild, lastCommitBitmap, lastCommit, producePrevMeta);
          tSubsidiesEnd = Date.now();
          void t0;
        },
        applyCronHook: async (s, ctx) => {
          if (!ctx) return;
          const t0 = Date.now();
          await this.applyCronJobs(s, blockNumberToBuild, ctx.blockTimestamp);
          tCronEnd = Date.now();
          void t0;
        }
      });
      const tBuildEnd = Date.now();
      block = result.block;
      receipts = result.receipts;
      this.cachedProposal = { height: currentHeight, block, receipts, journal: producerJournal };
      console.log(
        `[produce-timing] h=${blockNumberToBuild} total=${tBuildEnd - tStart}ms drain=${tDrainEnd - tStart}ms cron=${tCronEnd - tDrainEnd}ms subsidies=${tSubsidiesEnd - tCronEnd}ms post=${tBuildEnd - tSubsidiesEnd}ms txs=${pending.length}`
      );
    }
    console.log(`[producer] broadcasting proposal h=${block.header.number} r=${this.consensusEngine.getRound()}`);
    this.cacheProposal(
      BlockSchema3.serialize(block),
      block.header.number,
      this.consensusEngine.getRound(),
      block.header.proposer
    );
    void this.emitProposalBroadcast(block, this.consensusEngine.getRound()).catch((err) => {
      console.log(`[proposal-broadcast] background failure: ${String(err)}`);
    });
    console.log(`[producer] entering voting cycle h=${block.header.number}`);
    try {
      if (isLPCEngine()) {
        await this.runLPCVotingCycleForOwnBlock(block);
      } else {
        await this.runVotingCycleForOwnBlock(block);
      }
    } catch (err) {
      console.log(
        `[bft] own-block voting cycle failed at h=${block.header.number}: ${err instanceof Error ? err.message : String(err)} \u2014 rolling back + advancing round`
      );
      if (producerJournal) {
        await producerJournal.rollback();
      }
      if (!isLPCEngine()) {
        this.cachedProposal = null;
        this.consensusEngine.advanceRound();
        this.armProposalTimeoutIfNotProposer();
      }
      return null;
    }
    console.log(`[producer] voting cycle complete h=${block.header.number}`);
    if (producerJournal) {
      await producerJournal.commit();
    }
    await this.blockStore.putBlock(block);
    await this.blockStore.putReceipts(block.header.number, receipts);
    this.latestHeader = block.header;
    this.recordProposerAlive(block.header.proposer, block.header.number);
    const ownBlockHash = blake318(BlockHeaderSchema4.serialize(block.header));
    this.prevBlockMeta = await this.captureCurrentBlockMeta(block, ownBlockHash);
    await this.voteRecord.pruneAfterCommit(block.header.number).catch((err) => {
      console.error("[vote-record] prune failed (non-fatal):", err);
    });
    await this.maybeSubmitEpochEntry(block.header.number).catch((err) => {
      console.error("[epoch-entry] auto-submit hook failed (non-fatal):", err);
    });
    const faucetAccount = await this.stateStore.getAccount(this.faucet.address);
    if (faucetAccount && faucetAccount.nonce > this.faucetNextNonce) {
      this.faucetNextNonce = faucetAccount.nonce;
    }
    this.emitBlockProduced(block);
    await this.resetConsensusForNextHeight();
    return block;
  }
  /**
   * Run the pre-vote / pre-commit / commit BFT cycle for a block this
   * node just built.
   *
   *   1. Broadcast our prevote, ingest locally
   *   2. Wait for 2f+1 prevotes for this block hash (or timeout)
   *   3. Broadcast our precommit, ingest locally
   *   4. Wait for 2f+1 precommits for this block hash (or timeout)
   *   5. Return — caller persists the block as the new head
   *
   * Throws on quorum timeout — the producer must NOT persist a block
   * the network hasn't ratified.
   */
  async runVotingCycleForOwnBlock(block) {
    if (this.consensusEngine.getHeight() !== block.header.number) {
      throw new Error(
        `consensus height drift: engine=${this.consensusEngine.getHeight()} block=${block.header.number}`
      );
    }
    const blockHash = blake318(BlockHeaderSchema4.serialize(block.header));
    const blockHashHex = bytesToHex16(blockHash).toLowerCase();
    const round = this.consensusEngine.getRound();
    this.heightBlockCache.set(blockHashHex, block);
    const { voteHash, voteBlockHash } = this.resolvePrevoteTarget(blockHash, blockHashHex, null);
    this.consensusEngine.setPhase("prevote");
    const prevote = await this.signVoteSafely({
      kind: "prevote",
      height: block.header.number,
      round,
      blockHash: voteBlockHash
    });
    if (prevote) {
      const prevoteResult = this.ingestConsensusVote(prevote);
      if (!prevoteResult.ok) {
        throw new Error(`prevote ingest failed: ${prevoteResult.reason}`);
      }
    }
    await this.consensusEngine.waitForQuorum("prevote", voteBlockHash, this.scaleTimeoutForRound(this.voteTimeoutMs));
    const precommit = await this.signVoteSafely({
      kind: "precommit",
      height: block.header.number,
      round,
      blockHash: voteBlockHash
    });
    if (precommit) {
      const precommitResult = this.ingestConsensusVote(precommit);
      if (!precommitResult.ok) {
        throw new Error(`precommit ingest failed: ${precommitResult.reason}`);
      }
    }
    await this.consensusEngine.waitForQuorum(
      "precommit",
      voteBlockHash,
      this.scaleTimeoutForRound(this.voteTimeoutMs),
      this.quorumGraceMs
    );
    const commit = this.consensusEngine.commitResultFor(voteBlockHash);
    if (!commit.committed) {
      throw new Error(
        `block ${block.header.number}: no quorum after wait \u2014 precommits=${commit.count} stake=${commit.stake}/${commit.threshold}`
      );
    }
    if (voteHash !== blockHashHex) {
      console.log(
        `[locked-prevote] committed locked hash ${voteHash.slice(0, 14)}\u2026 (proposal was ${blockHashHex.slice(0, 14)}\u2026) \u2014 caller will resync`
      );
    }
  }
  /**
   * Locked-prevote target resolution (Tendermint §4.2).
   *
   * Returns the hash + Uint8Array we should sign our prevote and precommit
   * for. Three cases:
   *
   *   1. Not locked → vote for the round's proposal (current behavior).
   *   2. Locked, unlock condition met (polka observed at higher round
   *      for different value) → vote for the round's proposal.
   *   3. Locked, no unlock → re-prevote the locked hash IF we have its
   *      block cached at this height. If we don't have the block, fall
   *      through to voting for the round's proposal — the locked-hash
   *      majority will commit; we catch up via block-sync next height.
   */
  resolvePrevoteTarget(proposalHash, proposalHashHex, polRound) {
    const lock = this.consensusEngine.getLockedValue();
    if (!lock) {
      return { voteHash: proposalHashHex, voteBlockHash: proposalHash };
    }
    if (lock.hashHex === proposalHashHex) {
      return { voteHash: proposalHashHex, voteBlockHash: proposalHash };
    }
    if (this.consensusEngine.hasUnlockingPolka(lock.round, lock.hashHex)) {
      return { voteHash: proposalHashHex, voteBlockHash: proposalHash };
    }
    if (polRound !== null && polRound > lock.round) {
      console.log(
        `[pol-unlock] proposer presented polRound=${polRound} > lockedRound=${lock.round} \u2014 unlocking + voting proposal ${proposalHashHex.slice(0, 14)}\u2026`
      );
      return { voteHash: proposalHashHex, voteBlockHash: proposalHash };
    }
    const STALE_LOCK_K = 64n;
    const currentRound = this.consensusEngine.getRound();
    if (currentRound > lock.round + STALE_LOCK_K) {
      console.log(
        `[locked-prevote] stale-lock unlock: ${lock.hashHex.slice(0, 14)}\u2026 (r=${lock.round}) +${STALE_LOCK_K} rounds without commit \u2014 voting current proposal ${proposalHashHex.slice(0, 14)}\u2026`
      );
      return { voteHash: proposalHashHex, voteBlockHash: proposalHash };
    }
    const lockedHashBytes = hexToBytes11(lock.hashHex);
    const cached = this.heightBlockCache.get(lock.hashHex);
    if (cached) {
      console.log(
        `[locked-prevote] re-prevote locked ${lock.hashHex.slice(0, 14)}\u2026 (r=${lock.round}) instead of proposal ${proposalHashHex.slice(0, 14)}\u2026`
      );
    } else {
      console.log(
        `[locked-prevote] re-prevote locked ${lock.hashHex.slice(0, 14)}\u2026 (r=${lock.round}) \u2014 block not cached, voting hash anyway (block-sync will recover)`
      );
    }
    return { voteHash: lock.hashHex, voteBlockHash: lockedHashBytes };
  }
  /**
   * Tendermint locked-prevote check (paper §4.2). If we have an existing
   * polka-lock on a different value AND we haven't seen a polka for a
   * different value at any round > lockedRound (the "unlock" condition),
   * we must abstain from voting for this round's proposed block.
   *
   * Returns the lock info when abstention is required, null otherwise.
   */
  shouldAbstainDueToLock(proposedHashHex) {
    const lock = this.consensusEngine.getLockedValue();
    if (!lock) return null;
    if (lock.hashHex === proposedHashHex) return null;
    if (this.consensusEngine.hasUnlockingPolka(lock.round, lock.hashHex)) return null;
    return { lockedRound: lock.round, lockedHashHex: lock.hashHex };
  }
  /**
   * Apply a block received from an external source (network sync or gossip).
   *
   * Validates parent linkage, block number continuity, and computed state
   * root before committing. Optionally accepts the proposer's BFT round
   * so the receiver can sync its local engine round before casting its
   * replica vote.
   */
  /** Idempotent background backfill. If a loop is already running,
   * just bumps the target. Failures are logged; the next forward-gap
   * proposal will re-arm. */
  kickOffCatchup(targetHeight) {
    if (this.catchupPeerUrls.length === 0) {
      const seenKey = targetHeight.toString();
      if (!this.emptyCatchupPeersLogged.has(seenKey)) {
        this.emptyCatchupPeersLogged.add(seenKey);
        console.warn(
          `[block-sync] behind-detect requested catchup to h=${targetHeight} but ASENTUM_CATCHUP_PEERS is empty \u2014 no peers to pull from`
        );
      }
      return;
    }
    if (this.catchupInFlight !== null) {
      if (targetHeight > this.catchupTarget) {
        this.catchupTarget = targetHeight;
      }
      return;
    }
    this.catchupTarget = targetHeight;
    const loop = this.runCatchup().catch((err) => {
      console.log(`[block-sync] catchup loop failed: ${String(err)}`);
    }).finally(() => {
      this.catchupInFlight = null;
    });
    this.catchupInFlight = loop;
  }
  /** Pull missing blocks one-by-one until we reach `catchupTarget`,
   * round-robining peers per block. Each fetched block goes through
   * `applyExternalBlock` so parent-hash + state-root invariants still
   * gate acceptance.
   *
   * Three watchdogs cover the failure modes:
   *   1. STALL_MS — head not advancing at all (wedged on apply / 5xx).
   *   2. Widening gap — making forward progress but slower than the
   *      cluster produces, so (target - head) grows over a sliding
   *      window. Without this the loop runs forever, never closes the
   *      gap, AND blocks every other behind-shortcut from re-arming
   *      because catchupInFlight stays true.
   *   3. Parent-hash mismatch — we forked at some earlier height. Walk
   *      back N blocks and re-fetch so a deterministic-compute drift
   *      at h=K doesn't permanently wedge sync from h=K+1 onward.
   */
  async runCatchup() {
    console.log(`[block-sync] starting catchup: local=${this.latestHeader.number} target=${this.catchupTarget}`);
    let peerIdx = 0;
    let consecutiveFailures = 0;
    const STALL_MS = 3e4;
    let lastProgressMs = Date.now();
    let lastProgressHeight = this.latestHeader.number;
    const GAP_WINDOW_MS = 3e4;
    const GAP_WIDENING_GROWTH_BLOCKS = 5n;
    let widenStartMs = null;
    let widenStartGap = this.catchupTarget - this.latestHeader.number;
    while (this.latestHeader.number < this.catchupTarget) {
      const gap = this.catchupTarget - this.latestHeader.number;
      if (Date.now() - lastProgressMs > STALL_MS) {
        console.log(
          `[block-sync] STALL detected at h=${this.latestHeader.number} target=${this.catchupTarget} (no progress for ${Math.round((Date.now() - lastProgressMs) / 1e3)}s) \u2014 bailing so next gap can re-arm`
        );
        return;
      }
      if (widenStartMs === null) {
        widenStartMs = Date.now();
        widenStartGap = gap;
      } else if (gap > widenStartGap + GAP_WIDENING_GROWTH_BLOCKS) {
        if (Date.now() - widenStartMs > GAP_WINDOW_MS) {
          console.log(
            `[block-sync] widening-gap detected: gap grew from ${widenStartGap} to ${gap} over ${Math.round((Date.now() - widenStartMs) / 1e3)}s at h=${this.latestHeader.number} target=${this.catchupTarget} \u2014 bailing so next gap re-arms with current target`
          );
          return;
        }
      } else {
        widenStartMs = Date.now();
        widenStartGap = gap;
      }
      const wantHeight = this.latestHeader.number + 1n;
      const peer = this.catchupPeerUrls[peerIdx % this.catchupPeerUrls.length];
      peerIdx += 1;
      try {
        const ac = new AbortController();
        const timer = setTimeout(() => ac.abort(), 1e4);
        let rawHex;
        try {
          const url = `${peer}/raw-block/${wantHeight}`;
          let res;
          try {
            res = await fetch(url, { signal: ac.signal });
          } catch (fetchErr) {
            const cause = fetchErr?.cause;
            const causeStr = cause ? ` cause=${cause.code ?? cause.message ?? JSON.stringify(cause)}` : "";
            throw new Error(`fetch ${url} failed: ${fetchErr.message}${causeStr}`);
          }
          if (!res.ok) {
            throw new Error(`peer ${peer} returned ${res.status}`);
          }
          const body = await res.json();
          if (!body.rawBlock) throw new Error(`peer ${peer} returned no rawBlock`);
          rawHex = body.rawBlock;
        } finally {
          clearTimeout(timer);
        }
        const bytes = hexToBytes11(rawHex);
        await this.applyExternalBlock(bytes);
        if (this.latestHeader.number === wantHeight) {
          consecutiveFailures = 0;
          if (this.latestHeader.number > lastProgressHeight) {
            lastProgressHeight = this.latestHeader.number;
            lastProgressMs = Date.now();
          }
        } else {
          throw new Error(`apply did not advance head past ${wantHeight}`);
        }
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes("parent hash mismatch") || msg.includes("state root mismatch")) {
          console.log(
            `[block-sync] FORK detected at h=${wantHeight} (${msg}) local chain has diverged from canonical`
          );
          if (process.env.ASENTUM_AUTO_RECOVER_FORK === "1") {
            void this.autoRecoverFromFork(wantHeight, msg);
            return;
          }
          console.log(
            `[block-sync] auto-recovery disabled (set ASENTUM_AUTO_RECOVER_FORK=1 to enable). Operator must wipe blocks+state manually.`
          );
          return;
        }
        consecutiveFailures += 1;
        if (consecutiveFailures >= this.catchupPeerUrls.length * 2) {
          console.log(`[block-sync] giving up at h=${wantHeight} after ${consecutiveFailures} consecutive failures (last: ${msg})`);
          return;
        }
      }
    }
    console.log(`[block-sync] catchup complete at h=${this.latestHeader.number}`);
  }
  /**
   * Task #292 opt-in fork auto-recovery. Called from the block-sync loop
   * when a parent-hash or state-root mismatch confirms we're on a
   * diverged chain. Archives blocks/ + state/ + votes.json to timestamped
   * siblings, wipes ephemeral state files that would drag divergence
   * across the restart, and exits with a distinct code so systemd's
   * Restart=on-failure brings the process back on a clean data dir.
   *
   * Preserved across recovery: validator.key, validator.key.hex,
   * genesis-spec.json. Everything else is disposable and will be
   * rebuilt from snapshot + block-sync on the next boot.
   *
   * Opt-in via ASENTUM_AUTO_RECOVER_FORK=1 because operators without
   * systemd (Docker, foreground shell, tests) will not restart on exit
   * and would just die.
   */
  async autoRecoverFromFork(atHeight, reason) {
    const fs4 = await import("fs/promises");
    const { join: join8 } = await import("path");
    const ts = Math.floor(Date.now() / 1e3);
    const archive = async (name) => {
      const src = join8(this.dataDir, name);
      const dst = `${src}.forked-${ts}`;
      try {
        await fs4.rename(src, dst);
        console.log(`[fork-recover] archived ${name} -> ${dst}`);
      } catch (err) {
        const code = err.code;
        if (code !== "ENOENT") {
          console.warn(`[fork-recover] archive of ${name} failed: ${err}`);
        }
      }
    };
    console.log(`[fork-recover] BEGIN at h=${atHeight} (reason: ${reason})`);
    await archive("blocks");
    await archive("state");
    await archive("votes.json");
    await archive("last-commit-cert.json");
    await archive("liveness-state.json");
    console.log(`[fork-recover] archive complete, exiting with code 42 for systemd restart`);
    setTimeout(() => process.exit(42), 100);
  }
  async applyExternalBlock(bytes, opts = {}) {
    const mine = this.withApplyLock(() => this.applyExternalBlockInner(bytes, opts));
    this.externalApplyInFlight = mine;
    return mine;
  }
  async applyExternalBlockInner(bytes, opts = {}) {
    const tentative = await this.applyExternalBlockTentative(bytes, opts);
    if (tentative.alreadyHave) {
      return tentative.block;
    }
    await this.commitTentativeBlock(tentative.block, tentative.rebuiltReceipts, tentative.journaled);
    await this.resetConsensusForNextHeight();
    return tentative.block;
  }
  /**
   * BFT-gated proposal acceptance. Used by the `/consensus/proposal`
   * endpoint, which receives a block that has NOT yet been finalized.
   *
   * Returns AFTER tentative apply (state-root + receipts-root validated,
   * mutations recorded in a journal), but BEFORE the BFT cycle completes.
   * The BFT cycle runs in the background:
   *
   *   1. Cast our prevote, wait for 2f+1 prevotes for this block hash.
   *   2. Cast our precommit, wait for 2f+1 precommits for this block hash.
   *   3. On quorum: commit (persist block + advance head + emit + reset).
   *   4. On timeout: roll back the journal — state is restored to its
   *      pre-apply contents — and reset consensus.
   *
   * The HTTP response returning fast is critical for liveness: the
   * proposer awaits its own proposal-broadcast `Promise.all` before
   * casting its own vote, so peers must respond before voting (otherwise
   * the proposer never gets to cast its tipping vote and quorum stalls).
   *
   * This is the fix for the receive-proposal commit-before-quorum bug.
   * Previously a peer would persist the block immediately after applying
   * it, so a round-skip race could leave different peers committed to
   * different blocks at the same height.
   */
  async applyProposalWithBft(bytes, opts = {}) {
    const BEHIND_THRESHOLD = 10n;
    const maxSeen = this.consensusEngine.getMaxSeenVoteHeight();
    if (maxSeen > this.latestHeader.number + BEHIND_THRESHOLD) {
      console.log(
        `[behind-shortcut] maxSeenVoteHeight=${maxSeen} latestHeader=${this.latestHeader.number} gap=${maxSeen - this.latestHeader.number} \u2265 ${BEHIND_THRESHOLD} \u2014 skipping BFT, kicking catchup to ${maxSeen}`
      );
      this.kickOffCatchup(maxSeen);
      return { accepted: false, reason: "behind-cluster-catchup-in-progress" };
    }
    const previousLock = this.applyMutex;
    let release = () => {
    };
    const finalizationDone = new Promise((res) => {
      release = res;
    });
    this.applyMutex = finalizationDone;
    try {
      await previousLock.catch(() => {
      });
    } catch {
    }
    let tentative;
    try {
      tentative = await this.applyExternalBlockTentative(bytes, opts);
    } catch (err) {
      release();
      this.armProposalTimeoutIfNotProposer();
      return { accepted: false, reason: String(err instanceof Error ? err.message : err) };
    }
    if (tentative.alreadyHave) {
      release();
      void this.signLatePrecommitIfEligible(tentative.block, opts.proposerRound).catch((err) => {
        console.error(`[pull-mode/late-precommit] sign failed h=${tentative.block.header.number}: ${err instanceof Error ? err.message : String(err)}`);
      });
      return { accepted: true, block: tentative.block };
    }
    this.cacheProposal(
      bytes,
      tentative.block.header.number,
      this.consensusEngine.getRound(),
      tentative.block.header.proposer
    );
    const finalize = this.finalizeProposalBft(tentative, opts.polRound ?? null).catch((err) => {
      console.error("[bft] proposal finalize crashed:", err);
      return null;
    }).finally(() => release());
    this.externalApplyInFlight = finalize;
    return { accepted: true, block: tentative.block };
  }
  /**
   * Run the BFT cycle for a tentatively-applied external block, then
   * commit-or-rollback. Runs in the background — the HTTP handler
   * already responded before this started.
   */
  async finalizeProposalBft(tentative, polRound) {
    const block = tentative.block;
    const blockHash = blake318(BlockHeaderSchema4.serialize(block.header));
    try {
      if (isLPCEngine()) {
        await this.runLPCVotingCycleForExternalBlock(block, blockHash);
      } else {
        await this.runVotingCycleForExternalBlock(block, blockHash, polRound);
      }
    } catch (err) {
      console.log(
        `[bft] proposal h=${block.header.number} did not reach quorum \u2014 rolling back: ${err instanceof Error ? err.message : String(err)}`
      );
      await this.rollbackTentativeBlock(tentative.journaled);
      this.consensusEngine.advanceRound();
      this.armProposalTimeoutIfNotProposer();
      return null;
    }
    await this.commitTentativeBlock(block, tentative.rebuiltReceipts, tentative.journaled);
    await this.resetConsensusForNextHeight();
    return block;
  }
  /**
   * Validate + apply transactions against a journaled view of the state
   * store. Does NOT persist the block or advance latestHeader. The caller
   * decides whether to commit or rollback.
   *
   * Returns `alreadyHave: true` if the block is at-or-below our head;
   * the journal in that case is empty.
   */
  async applyExternalBlockTentative(bytes, opts = {}) {
    const block = BlockSchema3.deserialize(new Uint8Array(bytes));
    if (block.header.number <= this.latestHeader.number) {
      return {
        alreadyHave: true,
        block,
        rebuiltReceipts: [],
        journaled: new JournaledStateStore(this.stateStore)
      };
    }
    const nextExpectedHeight = this.latestHeader.number + 1n;
    const isNextBlock = block.header.number === nextExpectedHeight;
    if (opts.proposerRound !== void 0 && isNextBlock) {
      const localRound = this.consensusEngine.getRound();
      if (opts.proposerRound < localRound) {
        const blockHash = blake318(BlockHeaderSchema4.serialize(block.header));
        const blockHashHex = bytesToHex16(blockHash).toLowerCase();
        this.heightBlockCache.set(blockHashHex, block);
        console.log(
          `[stale-proposal] cached for commit-from-polka: h=${block.header.number} proposerRound=${opts.proposerRound} localRound=${localRound} hash=${blockHashHex.slice(0, 14)}\u2026`
        );
        return {
          alreadyHave: true,
          block,
          rebuiltReceipts: [],
          journaled: new JournaledStateStore(this.stateStore)
        };
      }
      this.clearProposalTimeout();
      if (opts.proposerRound > localRound) {
        const MAX_ROUND_CATCHUP = 2000n;
        const ahead = opts.proposerRound - localRound;
        if (ahead > MAX_ROUND_CATCHUP) {
          throw new Error(
            `applyExternalBlock: proposal too far ahead, proposerRound=${opts.proposerRound} vs localRound=${localRound} (max catchup ${MAX_ROUND_CATCHUP}) at height ${block.header.number}`
          );
        }
        const steps = Number(ahead);
        for (let i = 0; i < steps; i++) {
          this.consensusEngine.advanceRound();
        }
      }
    }
    if (block.header.number !== this.latestHeader.number + 1n) {
      if (block.header.number > this.latestHeader.number + 1n) {
        this.kickOffCatchup(block.header.number);
      }
      throw new Error(
        `applyExternalBlock: block number ${block.header.number} out of order (latest is ${this.latestHeader.number})`
      );
    }
    const expectedParent = blake318(BlockHeaderSchema4.serialize(this.latestHeader));
    if (!bytesEqual13(block.header.parentHash, expectedParent)) {
      throw new Error(
        `applyExternalBlock: parent hash mismatch at block ${block.header.number}`
      );
    }
    if (block.header.number > 1n) {
      const certErr = this.verifyLastCommit(block, expectedParent);
      if (certErr !== null) {
        throw new Error(
          `applyExternalBlock: lastCommit verification failed at block ${block.header.number}: ${certErr}`
        );
      }
    }
    const journaled = new JournaledStateStore(this.stateStore);
    const storeForApply = journaled;
    const execContext = {
      vm: this.vm,
      chainId: this.chainId,
      blockNumber: block.header.number,
      blockTimestamp: block.header.timestamp
    };
    const rebuiltReceipts = [];
    try {
      await this.applyCronJobs(
        storeForApply,
        block.header.number,
        block.header.timestamp
      );
      for (let txIndex = 0; txIndex < block.transactions.length; txIndex++) {
        const tx = block.transactions[txIndex];
        if (!tx) continue;
        const result = await applyTransaction(storeForApply, tx, execContext);
        if (!result.ok) {
          throw new Error(
            `applyExternalBlock: tx application failed at block ${block.header.number}: ${result.reason}`
          );
        }
        const txHash3 = blake318(SignedTransactionSchema5.serialize(tx));
        rebuiltReceipts.push(
          buildReceipt(txHash3, block.header.number, txIndex, {
            kind: result.kind,
            senderAddress: result.senderAddress,
            recipientAddress: result.recipientAddress,
            gasUsed: result.gasUsed,
            events: result.events,
            returnValue: result.returnValue,
            success: result.status,
            errorReason: result.status === 0 ? result.reason : void 0
          })
        );
      }
      if (block.header.number > 1n && (!this.prevBlockMeta || this.prevBlockMeta.blockNumber !== this.latestHeader.number)) {
        this.prevBlockMeta = await this.reconstructPrevBlockMeta(this.latestHeader);
      }
      await this.applyBlockSubsidies(
        storeForApply,
        block.header.number,
        block.header.lastCommitBitmap,
        block.lastCommit
      );
      const computedStateRoot = await journaled.stateRoot();
      if (!bytesEqual13(computedStateRoot, block.header.stateRoot)) {
        if (process.env.ASENTUM_DEBUG_PREAPPLY_DUMP === "1") {
          await dumpDrift({
            dataDir: join6(this.dataDir, "preapply"),
            stateStore: this.stateStore,
            block,
            expectedStateRoot: block.header.stateRoot,
            computedStateRoot,
            rebuiltReceipts,
            subsidyInputs: this.lastSubsidyInputs
          });
        }
        const dumpPath = await dumpDrift({
          dataDir: this.dataDir,
          stateStore: journaled,
          block,
          expectedStateRoot: block.header.stateRoot,
          computedStateRoot,
          rebuiltReceipts,
          subsidyInputs: this.lastSubsidyInputs
        });
        throw new Error(
          `applyExternalBlock: state root mismatch at block ${block.header.number} (computed ${bytesToHex16(computedStateRoot)} vs block ${bytesToHex16(block.header.stateRoot)})` + (dumpPath ? ` \u2014 drift dump written to ${dumpPath}` : "")
        );
      }
      const computedReceiptsRoot = computeReceiptsRoot(rebuiltReceipts);
      if (!bytesEqual13(computedReceiptsRoot, block.header.receiptsRoot)) {
        throw new Error(
          `applyExternalBlock: receipts root mismatch at block ${block.header.number} (computed ${bytesToHex16(computedReceiptsRoot)} vs block ${bytesToHex16(block.header.receiptsRoot)})`
        );
      }
    } catch (err) {
      await journaled.rollback();
      throw err;
    }
    return { alreadyHave: false, block, rebuiltReceipts, journaled };
  }
  /**
   * Promote a tentative apply into the canonical chain head.
   * Persists the block + receipts, advances latestHeader, syncs the
   * faucet nonce, and emits the block-produced listeners.
   */
  async commitTentativeBlock(block, rebuiltReceipts, journaled) {
    await journaled.commit();
    await this.blockStore.putBlock(block);
    await this.blockStore.putReceipts(block.header.number, rebuiltReceipts);
    this.latestHeader = block.header;
    this.recordProposerAlive(block.header.proposer, block.header.number);
    await this.maybeScheduleV3Reconfigure(block.header.number);
    const externalBlockHash = blake318(BlockHeaderSchema4.serialize(block.header));
    this.prevBlockMeta = await this.captureCurrentBlockMeta(block, externalBlockHash);
    await this.voteRecord.pruneAfterCommit(block.header.number).catch((err) => {
      console.error("[vote-record] prune failed (non-fatal):", err);
    });
    await this.maybeSubmitEpochEntry(block.header.number).catch((err) => {
      console.error("[epoch-entry] auto-submit hook failed (non-fatal):", err);
    });
    const faucetAccount = await this.stateStore.getAccount(this.faucet.address);
    if (faucetAccount && faucetAccount.nonce > this.faucetNextNonce) {
      this.faucetNextNonce = faucetAccount.nonce;
    }
    this.emitBlockProduced(block);
  }
  /**
   * Undo a tentative apply. Used when BFT quorum doesn't reach for the
   * proposed block, leaving the underlying state exactly as it was
   * before the apply attempt.
   */
  async rollbackTentativeBlock(journaled) {
    const undos = journaled.journalSize();
    await journaled.rollback();
    if (undos > 0) {
      console.log(`[bft] rolled back tentative apply (${undos} state undos)`);
    }
  }
  /**
   * Replica-side BFT cycle for a block we did NOT propose. Mirrors
   * `runVotingCycleForOwnBlock` but the round/proposer side is already
   * set by `applyExternalBlockTentative` (which advanced our engine
   * round to match the proposer's).
   *
   * Throws on quorum timeout — caller must roll back the tentative apply.
   */
  async runVotingCycleForExternalBlock(block, blockHash, polRound = null) {
    if (this.consensusEngine.getHeight() !== block.header.number) {
      throw new Error(
        `consensus height drift: engine=${this.consensusEngine.getHeight()} block=${block.header.number}`
      );
    }
    const validatorAddrHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    const inSet = this.consensusEngine.getValidators().some((v) => v.address.toLowerCase() === validatorAddrHex);
    const round = this.consensusEngine.getRound();
    const blockHashHex = bytesToHex16(blockHash).toLowerCase();
    this.heightBlockCache.set(blockHashHex, block);
    const { voteHash, voteBlockHash } = this.resolvePrevoteTarget(blockHash, blockHashHex, polRound);
    this.consensusEngine.setPhase("prevote");
    if (inSet) {
      const prevote = await this.signVoteSafely({
        kind: "prevote",
        height: block.header.number,
        round,
        blockHash: voteBlockHash
      });
      if (prevote) {
        const r = this.ingestConsensusVote(prevote);
        if (!r.ok) {
          throw new Error(`prevote ingest failed: ${r.reason}`);
        }
      }
    }
    await this.consensusEngine.waitForQuorum("prevote", voteBlockHash, this.scaleTimeoutForRound(this.voteTimeoutMs));
    if (inSet) {
      const precommit = await this.signVoteSafely({
        kind: "precommit",
        height: block.header.number,
        round,
        blockHash: voteBlockHash
      });
      if (precommit) {
        const r = this.ingestConsensusVote(precommit);
        if (!r.ok) {
          throw new Error(`precommit ingest failed: ${r.reason}`);
        }
      }
    }
    await this.consensusEngine.waitForQuorum(
      "precommit",
      voteBlockHash,
      this.scaleTimeoutForRound(this.voteTimeoutMs),
      this.quorumGraceMs
    );
    const commit = this.consensusEngine.commitResultFor(voteBlockHash);
    if (!commit.committed) {
      throw new Error(
        `block ${block.header.number}: no quorum after wait \u2014 precommits=${commit.count} stake=${commit.stake}/${commit.threshold}`
      );
    }
    if (voteHash !== blockHashHex) {
      console.log(
        `[locked-prevote] committed locked hash ${voteHash.slice(0, 14)}\u2026 (proposal was ${blockHashHex.slice(0, 14)}\u2026) \u2014 caller will resync`
      );
    }
  }
  /**
   * LPC voting cycle for own block (proposer path).
   *
   * Under LPC there is no prevote phase — finality is driven by a
   * single precommit round per height, aggregated by PrecommitManager.
   * The proposer signs one precommit for its own block, ingests it
   * locally (which the ANT/HTTP fan-out relays to peers via
   * onVoteProduced), and awaits PrecommitManager quorum via the
   * `waitForFinality` waiter that subscribes to `onBlockFinalized`.
   *
   * Semantics vs `runVotingCycleForOwnBlock`:
   *   - No prevote emit, no prevote-quorum wait, no POLKA, no LOCK.
   *   - No locked-prevote redirect — LPC's proposer builds the only
   *     valid block at its slot; peers either finalize it or the slot
   *     lapses and the next slot's proposer takes over.
   *   - `round` is always 0n — LPC has no rounds.
   *
   * The finality timeout scales the same way as the BFT voteTimeoutMs
   * so operators tuning `voteTimeoutMs` still apply under LPC.
   */
  async runLPCVotingCycleForOwnBlock(block) {
    const adapter = this.consensusEngine;
    const blockHash = blake318(BlockHeaderSchema4.serialize(block.header));
    const blockHashHex = bytesToHex16(blockHash).toLowerCase();
    if (this.consensusEngine.getHeight() > block.header.number) {
      const finalizedHash = adapter.getLPCEngine().finalizedHashAt(block.header.number);
      if (finalizedHash === blockHashHex) {
        console.log(
          `[lpc/own] engine already advanced to h=${this.consensusEngine.getHeight()} \u2014 our block h=${block.header.number} hash matches finalized, continuing to commit`
        );
        return;
      }
      throw new Error(
        `LPC height-drift with hash mismatch at h=${block.header.number}: local=${blockHashHex.slice(0, 14)}\u2026 finalized=${(finalizedHash ?? "null").slice(0, 14)}\u2026`
      );
    }
    if (this.consensusEngine.getHeight() !== block.header.number) {
      throw new Error(
        `LPC consensus height drift: engine=${this.consensusEngine.getHeight()} block=${block.header.number}`
      );
    }
    const currentRound = this.consensusEngine.getRound();
    const precommit = await this.signVoteSafely({
      kind: "precommit",
      height: block.header.number,
      round: currentRound,
      blockHash
    });
    if (precommit) {
      const r = this.ingestConsensusVote(precommit);
      if (!r.ok) {
        throw new Error(`LPC precommit ingest failed: ${r.reason}`);
      }
    }
    try {
      await adapter.waitForFinality(
        block.header.number,
        blockHashHex,
        this.scaleTimeoutForRound(this.voteTimeoutMs)
      );
    } catch (err) {
      const finalizedHash = adapter.getLPCEngine().finalizedHashAt(block.header.number);
      if (finalizedHash === blockHashHex) {
        console.log(`[lpc/own] h=${block.header.number} finality-race: timed out but engine already finalized this hash, continuing to commit`);
        return;
      }
      throw err;
    }
  }
  /**
   * LPC voting cycle for an externally-received block.
   *
   * Non-proposer path. Signs a single precommit for the tentatively-
   * applied block, ingests + relays it, waits for finality. If the
   * validator is not in the active set, precommit is skipped (same
   * dormant-observer semantics as BFT) but we still wait for peers
   * to finalize so the caller commits the tentative apply.
   */
  async runLPCVotingCycleForExternalBlock(block, blockHash) {
    if (this.consensusEngine.getHeight() !== block.header.number) {
      throw new Error(
        `LPC consensus height drift: engine=${this.consensusEngine.getHeight()} block=${block.header.number}`
      );
    }
    const validatorAddrHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    const inSet = this.consensusEngine.getValidators().some((v) => v.address.toLowerCase() === validatorAddrHex);
    const blockHashHex = bytesToHex16(blockHash).toLowerCase();
    if (inSet) {
      const currentRound = this.consensusEngine.getRound();
      const precommit = await this.signVoteSafely({
        kind: "precommit",
        height: block.header.number,
        round: currentRound,
        blockHash
      });
      if (precommit) {
        const r = this.ingestConsensusVote(precommit);
        if (!r.ok) {
          throw new Error(`LPC precommit ingest failed: ${r.reason}`);
        }
      }
    }
    const adapter = this.consensusEngine;
    try {
      await adapter.waitForFinality(
        block.header.number,
        blockHashHex,
        this.scaleTimeoutForRound(this.voteTimeoutMs)
      );
    } catch (err) {
      const finalizedHash = adapter.getLPCEngine().finalizedHashAt(block.header.number);
      if (finalizedHash === blockHashHex) {
        console.log(`[lpc/ext] h=${block.header.number} finality-race: timed out but engine already finalized this hash, continuing to commit`);
        return;
      }
      throw err;
    }
  }
  /**
   * Sign a vote with persistent double-sign protection. Records the
   * (height, round, kind, blockHash) tuple to disk BEFORE signing.
   * If a conflicting vote is already recorded at the same (h, r, kind),
   * throws — caller MUST NOT proceed to broadcast. If the same vote is
   * already recorded, returns null (caller can skip the broadcast or
   * re-sign and re-broadcast the existing vote at their discretion).
   *
   * This is the canonical anti-double-sign protection. Every signing
   * site in the codebase routes through here.
   */
  /**
   * Sign + ingest + broadcast a late precommit for a block we just learned
   * about via `tentative.alreadyHave` (sync-loop raced ahead of the
   * pull-mode proposal-poller). Called fire-and-forget from
   * `applyProposalWithBft`.
   *
   * Safety:
   *   - vote-record refuses if we already signed a precommit at (h, r),
   *     so this can't double-sign with a normal-path vote.
   *   - We only sign if we're in the engine's active committee at this
   *     height. Outside that we're either not a validator yet or Phase B
   *     has filtered us out — signing would just be discarded.
   *   - The relay's engine accepts this into its `lateCommittedPrecommits`
   *     buffer (height-and-hash matched, round not checked), and the
   *     next block's proposer unions it into the bitmap via
   *     `getLateCommittedSigners()`. See engine.ts ~line 425.
   *
   * Errors are caught by the caller's `.catch` — this method does not
   * throw; failures log and return.
   */
  async signLatePrecommitIfEligible(block, proposerRound) {
    const validatorAddrHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    const inSet = this.consensusEngine.getValidators().some((v) => v.address.toLowerCase() === validatorAddrHex);
    if (!inSet) return;
    if (proposerRound === void 0) return;
    const blockHash = blake318(BlockHeaderSchema4.serialize(block.header));
    let precommit;
    try {
      precommit = await this.signVoteSafely({
        kind: "precommit",
        height: block.header.number,
        round: proposerRound,
        blockHash
      });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      if (!msg.includes("vote-record")) {
        console.error(`[pull-mode/late-precommit] signVoteSafely threw: ${msg}`);
      }
      return;
    }
    if (!precommit) return;
    const r = this.ingestConsensusVote(precommit);
    if (r.ok) {
      console.log(
        `[pull-mode/late-precommit] signed + broadcast h=${block.header.number} r=${proposerRound} hash=${bytesToHex16(blockHash).slice(0, 14)}\u2026`
      );
    }
  }
  /**
   * #267 phase 4' — operator-side late-precommit signing driven by a
   * relay-fetched precommit polka.
   *
   * Called from the VotePoller after each tick that fetched a batch of
   * precommits at some height H. Runs the engine's non-mutating
   * `detectPrecommitPolka` over the batch. On hit:
   *   - we are bonded into the engine's accepted set
   *   - we have not already signed a precommit at (H, polkaRound) via
   *     normal flow (vote-record gates double-signing — signVoteSafely
   *     refuses and we silently skip)
   *   - we sign our own precommit for (H, polkaRound, blockHash) and
   *     route it through `ingestConsensusVote`, which dispatches to the
   *     relay-pinned vote-broadcaster (#265). The relay's existing
   *     late-precommit buffer (engine.ts L448-475) accepts it because
   *     height + hash match the relay's `lastCommittedHeight` /
   *     `lastCommittedHashHex`, and the next block's proposer unions
   *     it into the bitmap via `getLateCommittedSigners()` in
   *     `buildBitmapForNextBlock`.
   *
   * Engine state is NOT mutated here. The operator's local engine still
   * commits H via the normal block-sync path when applyProposalWithBft
   * delivers the canonical block. This call only buys the operator a
   * place in H's lastCommitBitmap that they would otherwise miss.
   *
   * No throws — failures log under [pull-mode/detect-polka] and return.
   */
  async detectAndSignLatePolka(forHeight, precommits) {
    if (this.consensusEngine.getHeight() > forHeight) return;
    if (this.consensusEngine.getHeight() === forHeight && this.consensusEngine.committedHash() !== null) {
      return;
    }
    const polka = this.consensusEngine.detectPrecommitPolka(forHeight, precommits);
    if (!polka) return;
    const validatorAddrHex = bytesToHex16(this.validatorKey.address).toLowerCase();
    const inSet = this.consensusEngine.getValidators().some((v) => v.address.toLowerCase() === validatorAddrHex);
    if (!inSet) return;
    const blockHashBytes = hexToBytes11(polka.blockHashHex);
    if (blockHashBytes.length !== 32) {
      console.error(
        `[pull-mode/detect-polka] BUG: blockHashHex=${polka.blockHashHex} produced ${blockHashBytes.length} bytes, expected 32`
      );
      return;
    }
    let precommit;
    try {
      precommit = await this.signVoteSafely({
        kind: "precommit",
        height: forHeight,
        round: polka.polkaRound,
        blockHash: blockHashBytes
      });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      if (!msg.includes("vote-record")) {
        console.error(`[pull-mode/detect-polka] signVoteSafely threw: ${msg}`);
      }
      return;
    }
    if (!precommit) return;
    const result = this.ingestConsensusVote(precommit);
    if (result.ok) {
      console.log(
        `[pull-mode/detect-polka] signed + broadcast h=${forHeight} r=${polka.polkaRound} hash=${polka.blockHashHex.slice(0, 14)}\u2026 signers=${polka.signerCount}`
      );
    }
  }
  async signVoteSafely(opts) {
    await this.voteRecord.record(opts.height, opts.round, opts.kind, opts.blockHash).catch((err) => {
      throw new Error(`[vote-record] refused to sign: ${err instanceof Error ? err.message : String(err)}`);
    });
    return buildAndSignVote({
      kind: opts.kind,
      chainId: this.chainId,
      height: opts.height,
      round: opts.round,
      blockHash: opts.blockHash,
      keypair: this.validatorKey.keypair
    });
  }
  /**
   * Ingest a consensus vote (peer-relayed OR locally-signed).
   *
   * Verifies signature, active-set membership, height/round/chainId,
   * then buckets the vote in the engine. If the vote is genuinely new
   * (not a duplicate of one this node has already seen), the
   * onVoteProduced listeners fire so the broadcaster can re-gossip
   * the vote to other peers. The engine's duplicate detection caps
   * the relay to one hop and breaks any rebroadcast loops.
   *
   * Used both by the producer's BFT cycle and by the `/consensus/vote`
   * RPC endpoint.
   */
  ingestConsensusVote(vote) {
    if (isLPCEngine()) {
      this.consensusEngine.observeVoteRound(vote.body.round);
    }
    const result = this.consensusEngine.ingestVote(vote);
    if (result.ok && !result.duplicate && !result.doubleSign) {
      this.emitVoteProduced(vote);
    }
    return result;
  }
  /** Snapshot of engine state for diagnostics / RPC exposure. */
  getConsensusState() {
    return this.consensusEngine.snapshot();
  }
  /**
   * @dev gateway-level filter: is this address in the current epoch's
   * active validator committee? Used by the consensus HTTP endpoints
   * (/consensus/proposal, /consensus/vote) to drop messages from
   * unauthorized senders BEFORE any processing. Without this filter
   * any peer running the node software can sign and broadcast a vote
   * or proposal, our code blindly advances local consensus state in
   * response, which lets one rogue peer desync the whole cluster's
   * round counter (observed in the wild 2026-05-22, peers at h=156k
   * spamming impossible round values pushed localRound to 90k+ and
   * stuck the chain for hours per incident).
   *
   * Uses the cached committee when available (fast path, ~microsecond)
   * and recomputes deterministically on cache miss.
   */
  async isInActiveValidatorSet(addressBytes) {
    if (addressBytes.length !== 20) return false;
    const targetHex = bytesToHex16(addressBytes).toLowerCase();
    try {
      const views = await this.fetchActiveValidatorViews();
      if (views.some((v) => v.address.toLowerCase() === targetHex)) return true;
      const bonded = await this.getActiveValidators();
      return bonded.some((v) => v.address.toLowerCase() === targetHex);
    } catch {
      return false;
    }
  }
  /** Apply an externally-received transaction into the mempool, without re-broadcasting. */
  submitExternalTransaction(bytes) {
    let tx;
    try {
      tx = SignedTransactionSchema5.deserialize(new Uint8Array(bytes));
    } catch (err) {
      return { accepted: false, reason: `invalid external tx SSZ: ${String(err)}` };
    }
    this.suppressTxBroadcast = true;
    try {
      return this.submitTransaction(tx);
    } finally {
      this.suppressTxBroadcast = false;
    }
  }
  emitBlockProduced(block) {
    void this.mempool.evictStale(async (addr) => {
      const account = await this.stateStore.getAccount(
        Uint8Array.from(Buffer.from(addr.replace("0x", ""), "hex"))
      );
      return account?.nonce ?? 0n;
    }).then((evicted) => {
      if (evicted > 0) console.log(`[mempool] evicted ${evicted} stale txs`);
    }).catch(() => {
    });
    for (const listener of this.blockListeners) {
      void Promise.resolve(listener(block)).catch((err) => {
        console.error("[node] block listener failed:", err);
      });
    }
  }
  emitTransactionSubmitted(tx) {
    if (this.suppressTxBroadcast) return;
    for (const listener of this.txListeners) {
      void Promise.resolve(listener(tx)).catch((err) => {
        console.error("[node] tx listener failed:", err);
      });
    }
  }
  emitVoteProduced(vote) {
    for (const listener of this.voteListeners) {
      void Promise.resolve(listener(vote)).catch((err) => {
        console.error("[node] vote listener failed:", err);
      });
    }
  }
  /**
   * Fire proposal listeners and AWAIT them. Unlike vote/block listeners
   * (which are fire-and-forget), proposal broadcast must complete
   * before the producer enters waitForQuorum, otherwise peer votes
   * have no chance of arriving in time. We swallow individual listener
   * errors but block the producer until each finishes (or throws).
   */
  async emitProposalBroadcast(block, round, polRound = null) {
    for (const listener of this.proposalListeners) {
      try {
        await listener(block, round, polRound);
      } catch (err) {
        console.error("[node] proposal listener failed:", err);
      }
    }
  }
  /**
   * Accept a signed transaction into the mempool. Does minimal validation;
   * deep checks (signature, nonce, balance) happen at block-production time.
   */
  submitTransaction(tx) {
    if (tx.signature.length === 0) {
      return { accepted: false, reason: "empty signature" };
    }
    if (tx.publicKey.length === 0) {
      return { accepted: false, reason: "empty public key" };
    }
    if (tx.body.chainId !== this.chainId) {
      return {
        accepted: false,
        reason: `wrong chainId: tx signed for ${tx.body.chainId}, chain is ${this.chainId}`
      };
    }
    const accepted = this.mempool.submit(tx);
    if (!accepted) {
      return { accepted: false, reason: "duplicate or mempool full" };
    }
    const txHash3 = blake318(SignedTransactionSchema5.serialize(tx));
    this.emitTransactionSubmitted(tx);
    return { accepted: true, txHash: txHash3 };
  }
  /**
   * Dev-only faucet transfer. Builds, signs, and enqueues a transaction
   * from the faucet account to the target address.
   */
  async faucetTransfer(to, amount) {
    if (amount <= 0n) return { accepted: false, reason: "amount must be positive" };
    const faucetAccount = await this.stateStore.getAccount(this.faucet.address);
    const faucetBalance = faucetAccount?.balance ?? 0n;
    const gasCost = TRANSFER_GAS_LIMIT * BASE_FEE_PER_GAS;
    const totalRequired = amount + gasCost;
    if (faucetBalance < totalRequired) {
      return {
        accepted: false,
        reason: `faucet depleted (have ${faucetBalance} wei, need ${totalRequired} wei). Ask the team to refill or request less.`
      };
    }
    const chainNonce = faucetAccount?.nonce ?? 0n;
    const faucetAddrHex = bytesToHex16(this.faucet.address).toLowerCase();
    const mempoolDepth = BigInt(this.mempool.sizeForSender(faucetAddrHex));
    const plausibleNext = chainNonce + mempoolDepth;
    if (this.faucetNextNonce > plausibleNext) {
      console.log(
        `[faucet] nonce cache resync: cache=${this.faucetNextNonce} \u2192 ${plausibleNext} (chain=${chainNonce} mempool=${mempoolDepth})`
      );
      this.faucetNextNonce = plausibleNext;
    }
    const nonce = this.faucetNextNonce;
    this.faucetNextNonce += 1n;
    const tx = buildAndSignTransfer({
      senderKeypair: this.faucet.keypair,
      chainId: this.chainId,
      nonce,
      to,
      value: amount,
      gasLimit: TRANSFER_GAS_LIMIT,
      maxFeePerGas: BASE_FEE_PER_GAS,
      maxPriorityFeePerGas: 0n
    });
    return this.submitTransaction(tx);
  }
  getChainInfo() {
    return {
      chainId: this.chainId,
      height: this.latestHeader.number,
      latestHeader: this.latestHeader,
      mempoolSize: this.mempool.size()
    };
  }
  /**
   * Snapshot of Phase B liveness state used by `/consensus/liveness`.
   * Baked addresses are always active regardless of counters. Non-baked
   * validators fall dormant after `maxConsecutiveMisses` consecutive
   * missed blocks or after their last vote falls outside the liveness
   * window. lastVotedHeight is bootstrapped from the last
   * livenessWindow blocks on boot, so a fresh restart will report empty
   * maps until the walk finishes.
   */
  getLivenessSnapshot() {
    const head = this.latestHeader.number.toString();
    const addrs = /* @__PURE__ */ new Set();
    for (const a of this.lastVotedHeight.keys()) addrs.add(a);
    for (const a of this.consecutiveMisses.keys()) addrs.add(a);
    for (const a of this.bakedAddressSet) addrs.add(a);
    const tracked = Array.from(addrs).sort().map((address) => {
      const lv = this.lastVotedHeight.get(address);
      return {
        address,
        lastVotedHeight: lv === void 0 ? null : lv.toString(),
        consecutiveMisses: this.consecutiveMisses.get(address) ?? 0,
        baked: this.bakedAddressSet.has(address)
      };
    });
    return {
      head,
      livenessWindow: this.livenessWindow.toString(),
      maxConsecutiveMisses: this.maxConsecutiveMisses,
      baked: Array.from(this.bakedAddressSet).sort(),
      tracked,
      // Task #344 — surface persistCert failures so an operator can
      // alert on non-zero writeFailureCount instead of tailing journals.
      certPersist: { ...persistCertStats },
      // Task #347 — surface per-peer vote-broadcast counters so we can
      // see which peers are dropping votes (abortedByTimeout > 0) vs
      // erroring (otherFailures > 0). Cumulative since process start.
      voteBroadcast: Array.from(voteBroadcastStats.perPeer.entries()).map(
        ([peer, s]) => ({ peer, ...s })
      )
    };
  }
  /**
   * Dump the full live account state. Used by the `/debug/state` endpoint
   * to diff a node's state against another node's state at the same height
   * — primary diagnostic tool for state-root drift.
   *
   * Returns the height + computed state root the dump corresponds to so a
   * caller can verify alignment with whatever the secondary captured.
   */
  async dumpStateForDebug() {
    const accounts = await this.stateStore.allAccounts();
    const stateRoot = await this.stateStore.stateRoot();
    return {
      chainHeight: this.latestHeader.number.toString(),
      stateRoot: bytesToHex16(stateRoot),
      capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
      accountCount: accounts.length,
      accounts: accounts.map(({ address, account }) => ({
        address: bytesToHex16(address),
        balance: account.balance.toString(),
        nonce: account.nonce.toString(),
        codeHash: bytesToHex16(account.codeHash),
        storageRoot: bytesToHex16(account.storageRoot)
      }))
    };
  }
  async getBalance(address) {
    const account = await this.stateStore.getAccount(address);
    return {
      balance: account?.balance ?? 0n,
      nonce: account?.nonce ?? 0n
    };
  }
  /**
   * Returns the full Account record for `address` at the current head, or
   * null when no such account exists. Use this for state-drift forensics:
   * `eth_getBalance` only exposes balance + nonce, but the state-root hash
   * also folds in codeHash + storageRoot, so a divergence isolated to
   * either of those is invisible at the eth_getBalance layer.
   */
  async getAccountFull(address) {
    const account = await this.stateStore.getAccount(address);
    if (!account) return null;
    return {
      balance: account.balance,
      nonce: account.nonce,
      codeHash: account.codeHash,
      storageRoot: account.storageRoot,
      height: this.latestHeader.number
    };
  }
  /**
   * Return the raw contract code bytes for the account at `address`,
   * or `null` if it's not a contract (empty codeHash) or doesn't
   * exist. Used by the `eth_getCode` RPC endpoint.
   */
  async getContractCode(address) {
    const account = await this.stateStore.getAccount(address);
    if (!account) return null;
    let isContract = false;
    for (let i = 0; i < account.codeHash.length; i++) {
      if (account.codeHash[i] !== 0) {
        isContract = true;
        break;
      }
    }
    if (!isContract) return null;
    return this.stateStore.getCode(account.codeHash);
  }
  /**
   * Read a raw contract storage slot by string key. Returns `null` if
   * not set. NOTE: this is NOT Ethereum-ABI-slot-compatible —
   * AsentumChain contracts use arbitrary string keys, not 32-byte
   * Solidity slots.
   */
  async getContractStorageValue(address, key6) {
    return this.stateStore.getStorageValue(address, key6);
  }
  async getBlock(number) {
    return this.blockStore.getBlockByNumber(number);
  }
  /**
   * Resolve a block by its BLAKE3-over-SSZ-header hash. Used by the
   * `eth_getBlockByHash` and `eth_getBlockTransactionCountByHash`
   * RPC endpoints.
   */
  async getBlockByHash(hash) {
    return this.blockStore.getBlockByHash(hash);
  }
  async getReceiptByTxHash(txHash3) {
    return this.blockStore.getReceiptByTxHash(txHash3);
  }
  /**
   * Query the staking system contract for the current bonded validator
   * set, then filter out any addresses that have been tombstoned by
   * the slashing contract. The result is the list of validators that
   * are eligible to participate in BFT consensus right now.
   *
   * Two read-only VM calls, no gas charged, no state mutation.
   */
  /**
   * Returns ALL bonded validators (including pending ones). Used by
   * the /validators RPC endpoint for informational display.
   */
  async getAllValidators() {
    const stakingResult = await runReadOnlyCall({
      store: this.stateStore,
      vm: this.vm,
      contractAddress: STAKING_CONTRACT_ADDRESS,
      method: "getValidators",
      args: [],
      blockNumber: this.latestHeader.number,
      blockTimestamp: this.latestHeader.timestamp,
      chainId: this.chainId
    });
    if (!stakingResult.ok) {
      throw new Error(`getAllValidators failed: ${stakingResult.reason}`);
    }
    return Array.isArray(stakingResult.returnValue) ? stakingResult.returnValue : [];
  }
  /**
   * Returns only validators that are ACTIVE in the consensus set —
   * i.e., their pending period has expired AND they're not tombstoned.
   * This is what the consensus engine reads for quorum calculations.
   */
  async getActiveValidators() {
    const stakingResult = await runReadOnlyCall({
      store: this.stateStore,
      vm: this.vm,
      contractAddress: STAKING_CONTRACT_ADDRESS,
      method: "getActiveValidators",
      args: [],
      blockNumber: this.latestHeader.number,
      blockTimestamp: this.latestHeader.timestamp,
      chainId: this.chainId
    });
    if (!stakingResult.ok) {
      return this.getActiveValidatorsFallback();
    }
    const bonded = Array.isArray(stakingResult.returnValue) ? stakingResult.returnValue : [];
    let tombstoned;
    try {
      const tombs = await this.getTombstones();
      tombstoned = new Set(tombs.map((t) => t.address.toLowerCase()));
    } catch {
      tombstoned = /* @__PURE__ */ new Set();
    }
    return bonded.filter((v) => !tombstoned.has(v.address.toLowerCase()));
  }
  /** Fallback for chains whose staking contract doesn't have getActiveValidators yet. */
  async getActiveValidatorsFallback() {
    const all = await this.getAllValidators();
    let tombstoned;
    try {
      const tombs = await this.getTombstones();
      tombstoned = new Set(tombs.map((t) => t.address.toLowerCase()));
    } catch {
      tombstoned = /* @__PURE__ */ new Set();
    }
    return all.filter((v) => !tombstoned.has(v.address.toLowerCase()));
  }
  /**
   * Query the slashing system contract for the current tombstone list.
   * Each entry is a record of a validator who's been slashed for
   * consensus violations (e.g. double-signing).
   */
  async getTombstones(pinTo) {
    const blockNumber = pinTo?.blockNumber ?? this.latestHeader.number;
    const blockTimestamp = pinTo?.blockTimestamp ?? this.latestHeader.timestamp;
    const result = await runReadOnlyCall({
      // Same seed-height-state rationale as getEpochCandidatesFiltered: on a
      // cold replayer the finalized this.stateStore lags the seed, so a
      // consensus-deterministic tombstone read must use the seed-height store
      // when the caller supplies one.
      store: pinTo?.store ?? this.stateStore,
      vm: this.vm,
      contractAddress: SLASHING_CONTRACT_ADDRESS,
      method: "getTombstones",
      args: [],
      blockNumber,
      blockTimestamp,
      chainId: this.chainId
    });
    if (!result.ok) {
      throw new Error(`getTombstones failed: ${result.reason}`);
    }
    return Array.isArray(result.returnValue) ? result.returnValue : [];
  }
  /**
   * Public-facing wrapper around computeCommitteeForEpoch for diagnostics
   * (the /participation RPC needs the same per-epoch committee the block
   * producer used to encode lastCommitBitmap, otherwise bits decode
   * against the wrong addresses and signing validators show as dormant).
   * Read-only; safe to call at any rate.
   *
   * MUST be the deterministic (pre-dormancy) committee: bitmaps are encoded
   * against it. The old non-deterministic call returned the dormancy-filtered
   * SUBSET, shifting every bit past a dropped member onto the wrong address —
   * /participation credited dead-but-graced validators with live voters'
   * signatures while the live voters showed dormant (prod 2026-08-04,
   * chain 1412, committee=17).
   */
  async getCommitteeForEpoch(epoch) {
    return this.computeCommitteeForEpoch(epoch, { deterministic: true });
  }
  // === v3 pull-mode (#424 externals sign+earn over HTTP) ================
  //
  // These wrappers expose the deterministic committee + the two ANT frame
  // surfaces (proposal pull, precommit push) over HTTP so an external that
  // cannot hold an ANT session still lands its GRANDPA bit in the cert. All
  // gated behind ASENTUM_PULL_MODE on the CLIENT side; the SERVER routes are
  // additive + read-only-plus-authenticated-write, so baked behavior is
  // byte-identical whether or not any external polls them.
  /** The epoch a height belongs to. Matches the consensus-layer division. */
  epochOfHeight(height) {
    return height / EPOCH_BLOCKS;
  }
  /**
   * PUBLIC wrapper over computeCommitteeForEpoch(epoch, {deterministic:true}).
   * The deterministic committee is the pure on-chain set (no local dormancy
   * filtering), so it is identical across nodes — the correct set to check
   * an inbound precommit's voter against, and the set the pull client checks
   * itself against before signing. (The private form is used elsewhere for
   * reward reconstruction; this just re-exports it for the RPC + net layers.)
   */
  async getDeterministicCommitteeForEpoch(epoch) {
    return this.computeCommitteeForEpoch(epoch, { deterministic: true });
  }
  /** Local validator address (20 bytes), or null on a non-validator node. */
  getSelfValidatorAddress() {
    return this.validatorKey?.address ?? null;
  }
  /**
   * Slot timing anchor for the v3 chain (slot0 start + slot duration), or null
   * pre-v3. Read-only view over the genesis spec, consumed by the pull
   * client's slot-prefetch window math (net/v3-pull.ts). Never feeds state
   * computation.
   */
  getV3SlotTiming() {
    if (!this.v3) return null;
    return {
      slot0UnixMs: this.v3.spec.slot0StartUnixMs,
      slotDurationMs: this.v3.spec.slotDurationMs
    };
  }
  /**
   * Client-side slot-prefetch (ASENTUM_SLOT_PREFETCH=1 externals only):
   * broadcast an ANT SYNC_REQUEST for [fromHeight, toHeight]. Byte-identical
   * wire to the engine's requestBlocks gap-request hook (two u64 BE heights);
   * peers answer from their recent-block ring with ordinary BLOCK frames that
   * flow back through the SAME onPeerBlock ingest — this changes WHEN we ask
   * for blocks, never what any node accepts. No-op when the ANT transport is
   * not wired (pure-HTTP external), where the prefetch degrades to the fast
   * poll cadence alone.
   */
  requestV3BlockRange(fromHeight, toHeight) {
    if (!this.v3SendSyncRequest) return;
    if (toHeight < fromHeight || fromHeight < 0n) return;
    const buf = new Uint8Array(16);
    const dv = new DataView(buf.buffer);
    dv.setBigUint64(0, fromHeight, false);
    dv.setBigUint64(8, toHeight, false);
    this.v3SendSyncRequest(buf);
  }
  /** True iff this node's address is NOT one of the baked genesis validators. */
  isExternalValidator() {
    const self = this.validatorKey?.address;
    if (!self) return false;
    return !this.bakedAddressSet.has(bytesToHex16(self).toLowerCase());
  }
  /** The engine's current GRANDPA finality head (last finalized height). */
  getV3FinalityHead() {
    if (!this.v3) return null;
    return this.v3.engine.getFinalityHead();
  }
  /**
   * The engine's fork-choice head — the highest CONTIGUOUSLY-APPLIED block.
   * Unlike getV3FinalityHead this advances on every applied peer block even for
   * a lone HTTP external whose local finality never reaches quorum, and unlike
   * an orphan-buffered block it only counts blocks whose parent chain is fully
   * applied (onPeerBlock buffers gaps as orphans without advancing the head).
   * That makes head+1 the correct pacing anchor for the pull cursor: it is
   * always the next block the external can pull, apply, and (if in committee)
   * sign — sequentially, so each epoch SEED block lands before any height in
   * that epoch is reached. Restart-safe: after boot-reconstruction it reflects
   * exactly what is applied in the block store.
   */
  getV3ChainHead() {
    if (!this.v3) return null;
    return this.v3.engine.getHead();
  }
  /**
   * Optimistic-inclusion plumbing (Fix 1a). Returns BOTH the finalized height
   * (what eth_blockNumber reports — the GRANDPA-committed head, ~FINALITY_LAG
   * blocks behind) AND the Aura tip (the highest applied/optimistic block, where
   * a just-submitted tx lands in <1s). A wallet/explorer can show "included at
   * tip" the instant a tx's receipt blockNumber is <= tip, then flip to
   * "finalized" once finalized advances past it — WITHOUT changing the meaning
   * of eth_blockNumber, which the indexer/pull-client/faucet depend on staying
   * = finalized. tip == finalized on a v2/non-v3 node (no separate Aura head).
   */
  getV3Heads() {
    const finalized = this.getChainInfo().height;
    const chainHead = this.getV3ChainHead();
    const tip = chainHead && chainHead.number > finalized ? chainHead.number : finalized;
    return { finalized, tip };
  }
  /**
   * GET /v3/commit?height=H backing (#418). Return the finalized GRANDPA commit
   * cert for height H (round == height, height-indexed) in the on-wire journal
   * form, or null if H is not finalized on this node. READ-ONLY — it only serves
   * already-public finalized data (bitmap + ML-DSA sigs), so a NAT'd pull-mode
   * node can fetch it, verify it against the committee at H, and apply it to
   * advance its OWN finalized head (which never reaches quorum locally because it
   * receives no peer precommits). commitByRound is never pruned (grandpa/store),
   * so a long-running baked serves every height finalized since its boot.
   */
  getV3Commit(height) {
    if (!this.v3) return null;
    const commit = this.v3.engine.commitAt(height);
    if (!commit) return null;
    return serialize(commit);
  }
  /**
   * #418 Part B: apply a finalized GRANDPA commit cert fetched over HTTP
   * (GET /v3/commit) to advance THIS node's finalized head. Used by the
   * pull-mode catchup client: a NAT'd node never reaches quorum locally
   * (it receives no peer precommits), so its finalized head pins. Here we
   * take the aggregated cert, unpack it into the individual precommits that
   * composed it, and feed each through the SAME verified ingest path a live
   * HTTP precommit takes (ingestV3HttpPrecommit: ML-DSA verify + committee
   * membership + engine.onPeerVote tally). Once the full quorum is fed, the
   * store finalizes to commit.target — no new consensus logic, just replay.
   * Returns whether our finalized head advanced.
   */
  async applyV3FinalityCommit(j) {
    if (!this.v3) return { applied: false, reason: "v3 not engaged", finalized: "0" };
    const commit = deserialize(j);
    const height = commit.target.number;
    const before = this.latestHeader.number;
    if (height <= before) {
      return { applied: false, reason: "already committed", finalized: before.toString() };
    }
    const epoch = this.epochOfHeight(height);
    let committee;
    try {
      committee = await this.computeCommitteeForEpoch(epoch, { deterministic: true });
    } catch (err) {
      return { applied: false, reason: `committee unresolvable: ${String(err)}`, finalized: before.toString() };
    }
    const cfg = {
      committee: committee.map((v) => hexToBytes11(v.address)),
      stake: committee.map((v) => BigInt(v.stake)),
      quorumNumerator: BigInt(this.v3.spec.quorumNumerator ?? 2),
      quorumDenominator: BigInt(this.v3.spec.quorumDenominator ?? 3)
    };
    let entries;
    try {
      entries = unpackSignedCommit(cfg, commit);
    } catch (err) {
      return { applied: false, reason: `unpack failed: ${String(err)}`, finalized: before.toString() };
    }
    const dbg = process.env.ASENTUM_DEBUG_CATCHUP === "1";
    for (const e of entries) {
      const vote = {
        body: { round: commit.round, kind: "precommit", target: commit.target, voter: e.voter },
        signature: e.signature
      };
      const voterHex = bytesToHex16(e.voter).toLowerCase();
      const pubkey = await this.resolveBondedPubkey(voterHex);
      if (!this.verifyV3VoteUnconditional(vote, pubkey)) {
        if (dbg)
          console.log(
            `[v3-catchup-apply] h=${height} REJECT: bad signature from voter=${voterHex.slice(0, 10)}`
          );
        return { applied: false, reason: `bad signature: ${voterHex.slice(0, 10)}`, finalized: before.toString() };
      }
    }
    let imported = false;
    if (height > this.v3.engine.getFinalityHead().number) {
      imported = !!this.v3.engine.importFinalityCommit(commit);
    }
    await (this.v3CommitChain = this.v3CommitChain.then(() => this.withApplyLock(() => this.commitV3Finalized(commit))).catch((err) => {
      console.error("[v3-catchup-commit] commit failed:", err);
    }));
    const after = this.latestHeader.number;
    if (dbg)
      console.log(
        `[v3-catchup-apply] h=${height} entries=${entries.length} sigs=ok imported=${imported ? "yes" : "no"} committed before=${before} after=${after}`
      );
    return {
      applied: after > before,
      reason: after > before ? void 0 : "target block not applied yet / walk incomplete",
      finalized: after.toString()
    };
  }
  /**
   * Vote-latency metrics (GET /v3/vote-metrics): per-committee-member
   * precommit arrival-vs-cert-consumption stats over the recent consumed-round
   * ring. Consumption is recorded on the PRODUCE path only, so a node's window
   * covers the rounds whose cert IT baked into a block. Null when v3 is off.
   */
  getV3VoteMetrics() {
    if (!this.v3) return null;
    return this.v3.engine.voteMetrics();
  }
  recordV3AuraHashForHeight(height, auraHash) {
    this.v3AuraHashByHeight.set(height, auraHash.slice());
    while (this.v3AuraHashByHeight.size > _AsentumNode.V3_AURA_HASH_WINDOW) {
      const oldest = this.v3AuraHashByHeight.keys().next().value;
      if (oldest === void 0) break;
      this.v3AuraHashByHeight.delete(oldest);
    }
  }
  /**
   * Serve GET /v3/proposal?height=H. Returns the `block-with-body` wire frame
   * (identical to the ANT PROPOSAL) for the block finalized at height H, plus
   * that block's Aura-header hash so the client can target its precommit —
   * or null when this baked has not finalized H yet (the route answers 204).
   *
   * Source of truth: the finalized block-store + the v3AuraHashByHeight index
   * (populated wherever v3FinalizedByAura is). We reconstruct the exact
   * AuraBlockHeader the producer hashed by using this height's stored Aura
   * hash's PREIMAGE fields — specifically parentAuraHash = the stored hash at
   * H-1 (genesis hash at H==1) and slot recovered from the block timestamp,
   * matching reconstructV3EngineOnBoot. If we hold H's own aura hash we return
   * it as `auraHash`; otherwise the frame is still self-describing (the client
   * re-hashes the header) so we can serve without it.
   */
  async getV3ProposalWire(height) {
    if (!this.v3) return null;
    if (height <= 0n) return null;
    const finalizedHead = this.v3LastFinalizedHeader?.number ?? this.latestHeader.number;
    if (height > finalizedHead) return null;
    const block = await this.blockStore.getBlockByNumber(height);
    if (!block) return null;
    let parentAuraHash;
    if (height === 1n) {
      const { buildGenesisHeader: buildGenesisHeader2 } = await Promise.resolve().then(() => (init_genesis_spec(), genesis_spec_exports));
      parentAuraHash = this.hashV3AuraHeader(buildGenesisHeader2(this.v3.spec));
    } else {
      const stored = this.v3AuraHashByHeight.get(height - 1n);
      if (!stored) return null;
      parentAuraHash = stored;
    }
    const { slotForTime: slotForTime2 } = await Promise.resolve().then(() => (init_slot(), slot_exports));
    const slotCfg = {
      genesisSlot0UnixMs: this.v3.spec.slot0StartUnixMs,
      slotDurationMs: this.v3.spec.slotDurationMs
    };
    const v2h = block.header;
    const aura = {
      parentHash: parentAuraHash,
      stateRoot: v2h.stateRoot,
      transactionsRoot: v2h.transactionsRoot,
      receiptsRoot: v2h.receiptsRoot,
      number: v2h.number,
      slot: slotForTime2(slotCfg, Number(v2h.timestamp)),
      timestamp: v2h.timestamp,
      gasLimit: v2h.gasLimit,
      gasUsed: v2h.gasUsed,
      baseFeePerGas: v2h.baseFeePerGas,
      proposer: v2h.proposer
    };
    const auraHash = this.hashV3AuraHeader(aura);
    const bodyBytes = BlockSchema3.serialize(block);
    const wire = encodeBlockWithBodyMessage(aura, bodyBytes);
    return { wire, auraHash };
  }
  /**
   * Serve GET /v3/finalized?height=H. Reports whether height H is finalized
   * and, if so, its Aura hash — so a pulling external can advance its own
   * finalized head to H without reconstructing quorum locally.
   */
  getV3FinalizedInfo(height) {
    const stored = this.v3AuraHashByHeight.get(height);
    const finalizedHead = this.v3LastFinalizedHeader?.number ?? this.latestHeader.number;
    if (stored && height <= finalizedHead) return { finalized: true, auraHash: stored };
    return { finalized: false };
  }
  /**
   * UNCONDITIONAL ML-DSA verification for the internet-facing POST
   * /v3/precommit endpoint. Unlike verifyPeerVoteV3 (env-gated, fails OPEN on
   * unknown pubkey) this ALWAYS verifies and REJECTS a voter whose committee
   * pubkey we do not hold — an unauthenticated write must never fold an
   * unverifiable bit into the cert. Returns false on bad sig, unknown pubkey,
   * or malformed signature bytes.
   */
  verifyV3VoteUnconditional(vote, resolvedPubkey) {
    const addr = bytesToHex16(vote.body.voter).toLowerCase();
    const pubkey = resolvedPubkey ?? this.v3CommitteePubkeys.get(addr);
    if (!pubkey) return false;
    try {
      const msg = serializeVoteBodyForV3(vote.body);
      return mlDsaVerify(msg, vote.signature, pubkey);
    } catch {
      return false;
    }
  }
  /**
   * Resolve a bonded validator's ML-DSA pubkey from the ON-CHAIN staking set
   * (deterministic, read-only). Returns the `bond(pubKey,...)`-registered key
   * for `voterHex` (lowercase 0x addr), or undefined if the address is not
   * bonded. This is the authoritative source for a POST-genesis external whose
   * key never entered the genesis-seeded v3CommitteePubkeys map.
   */
  async resolveBondedPubkey(voterHex) {
    try {
      const bonded = await this.getAllValidators();
      const rec = bonded.find((v) => v.address.toLowerCase() === voterHex);
      if (!rec || !rec.pubKey) return void 0;
      return hexToBytes11(rec.pubKey);
    } catch {
      return void 0;
    }
  }
  /**
   * Ingest a GRANDPA precommit that arrived over HTTP (POST /v3/precommit).
   * Order per spec: (a) caller decoded; (b) verify sig UNCONDITIONALLY +
   * reject unknown pubkey; (c) committee-membership check against the
   * deterministic committee for epochOf(target.number); (d) feed the SAME
   * engine.onPeerVote path an ANT VOTE frame hits, so tally + slashing +
   * cert-augmentation run identically. Idempotent on (height, voter) via the
   * store's equivocation/duplicate reject.
   */
  async ingestV3HttpPrecommit(vote) {
    if (!this.v3) return { accepted: false, reason: "v3 not engaged", finalized: false };
    if (vote.body.kind !== "precommit") {
      return { accepted: false, reason: "only precommits accepted", finalized: false };
    }
    const voterAddrHex = bytesToHex16(vote.body.voter).toLowerCase();
    const resolvedPubkey = await this.resolveBondedPubkey(voterAddrHex);
    if (!this.verifyV3VoteUnconditional(vote, resolvedPubkey)) {
      return { accepted: false, reason: "bad signature or unknown validator", finalized: false };
    }
    const epoch = this.epochOfHeight(vote.body.target.number);
    let committee;
    try {
      committee = await this.computeCommitteeForEpoch(epoch, { deterministic: true });
    } catch (err) {
      return { accepted: false, reason: `committee unresolvable: ${String(err)}`, finalized: false };
    }
    const voterHex = bytesToHex16(vote.body.voter).toLowerCase();
    if (!committee.some((v) => v.address.toLowerCase() === voterHex)) {
      return { accepted: false, reason: "voter not in deterministic committee", finalized: false };
    }
    const beforeHead = this.v3.engine.getFinalityHead().number;
    const ingest = this.v3.engine.onPeerVote(vote);
    const afterHead = this.v3.engine.getFinalityHead().number;
    if (this.v3SendVote && shouldRelayPrecommit({
      relayEnabled: isPrecommitRelayEnabled(),
      transport: "http",
      storeAccepted: ingest.accepted
    })) {
      this.v3SendVote(encodeVoteMessage(vote));
      this.v3RelayedPrecommits++;
      const nowMs = Date.now();
      if (nowMs - this.v3LastRelayLogMs > 1e4) {
        this.v3LastRelayLogMs = nowMs;
        console.log(
          `[v3-precommit-relay] relayed HTTP precommit to ANT peers (voter=${voterAddrHex.slice(0, 10)} h=${vote.body.target.number} total=${this.v3RelayedPrecommits})`
        );
      }
    }
    const finalized = afterHead >= vote.body.target.number;
    return { accepted: true, finalized: finalized || afterHead > beforeHead };
  }
  /**
   * Build + sign + self-record a GRANDPA precommit for `target` at
   * round==height (height-indexed finalization). Used by the pull client on
   * an external: it has applied the pulled block but will never see a local
   * prevote polka, so it precommits the baked-confirmed hash directly rather
   * than driving the GrandpaVoter tick. Records to the SAME SelfVoteRecord the
   * voter uses (#406 restart-safety) BEFORE signing, so a mid-sign crash can
   * never equivocate. Returns null if this node cannot sign (no key / no v3).
   */
  async signAndRecordV3Precommit(target) {
    if (!this.v3 || !this.validatorKey) return null;
    const body = {
      round: target.number,
      kind: "precommit",
      target: { hash: target.hash.slice(), number: target.number },
      voter: this.validatorKey.address.slice()
    };
    try {
      await this.v3.selfVoteRecord.record({ body, signature: new Uint8Array(0) });
    } catch (err) {
      console.warn(`[v3-pull] refusing to sign precommit (equivocation guard): ${err.message}`);
      return null;
    }
    const signature = mlDsaSign(serializeVoteBodyForV3(body), this.validatorKey.keypair.secretKey);
    this.v3.engine.onPeerVote({ body, signature });
    return { body, signature };
  }
  /**
   * Apply a pulled block (decoded `block-with-body` frame) through the SAME
   * engine.onPeerBlock path ANT uses — gives orphan/gap handling + the
   * applyBlock hook (stateRoot re-verify). Returns whether it was accepted
   * into the fork tree well enough to vote on (best-effort; onPeerBlock is
   * void, so we re-check the fork head afterwards is out of scope — the pull
   * client gates its precommit on committee membership + a non-null target).
   */
  async applyV3PulledBlock(header, bodyBytes) {
    if (!this.v3) return;
    await this.v3.engine.onPeerBlock({ header, bodyBytes });
  }
  /** Public wrapper: blake3 of the serialized AuraBlockHeader (fork-choice key). */
  hashAuraHeader(header) {
    return this.hashV3AuraHeader(header);
  }
  /** Encode a v3 GRANDPA vote into the same VOTE wire frame ANT carries. */
  encodeV3VoteFrame(vote) {
    return encodeVoteMessage(vote);
  }
  /**
   * Start the v3 pull-mode client, but ONLY when ASENTUM_PULL_MODE=1 AND the
   * v3 engine is active AND this node is an external (not baked) AND at least
   * one ASENTUM_PEER_RPC peer is configured. This is the missing wiring the
   * design calls out (grep ASENTUM_PULL_MODE node.ts == 0 before this). No-op
   * otherwise, so baked behavior is byte-identical.
   *
   * Fire-and-forget: the service runs its own loop until stopV3PullMode().
   */
  startV3PullMode() {
    if (process.env.ASENTUM_PULL_MODE !== "1") return;
    if (!this.v3) return;
    if (!this.isExternalValidator() && process.env.ASENTUM_FORCE_PULL_MODE !== "1") {
      console.log("[v3-pull] node is baked \u2014 pull-mode not started (baked use ANT)");
      return;
    }
    if (!this.isExternalValidator()) {
      console.log("[v3-pull] baked node with ASENTUM_FORCE_PULL_MODE=1 \u2014 starting pull-mode for catchup recovery");
    }
    const raw = process.env.ASENTUM_PEER_RPC ?? "";
    const peerUrls = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
    if (peerUrls.length === 0) {
      console.warn("[v3-pull] ASENTUM_PULL_MODE=1 but ASENTUM_PEER_RPC is empty \u2014 not started");
      return;
    }
    if (this.v3PullService) return;
    this.v3PullService = new V3PullService({ peerUrls, node: this });
    void this.v3PullService.start();
  }
  /** Stop the pull-mode client if running. Idempotent. */
  stopV3PullMode() {
    if (this.v3PullService) {
      this.v3PullService.stop();
      this.v3PullService = null;
    }
  }
  /** Exposed for diagnostics — keep in sync with the consensus-layer constant. */
  get epochBlocks() {
    return EPOCH_BLOCKS;
  }
  /** Exposed for diagnostics — window (in blocks) at the end of each epoch
   *  during which validators can submit a submitEpochEntry tx to join the
   *  next epoch's committee. Consumed by /validators to compute
   *  nextCommitteeEntryAt for the UI countdown widget. */
  get epochEntryWindow() {
    return EPOCH_ENTRY_WINDOW;
  }
  /** True if THIS node has already fired a submitEpochEntry tx for the
   *  keyed epoch (auto-submit path). Consumed by the operator UI to flip
   *  its waiting widget from "waiting for window" to "submitted — queued
   *  for next epoch". The pendingEpochEntry map is local-only; other
   *  nodes' submissions aren't observable from here. */
  hasSubmittedEpochEntry(epoch) {
    return this.pendingEpochEntry.has(String(epoch));
  }
  /**
   * Query the staking contract for the materials needed to assemble
   * the active committee for a given epoch — the top-K stable core
   * and the eligible (entry-tx-submitted) reserve pool. The shuffle
   * + final pick happens at the consensus layer, not here.
   *
   * Diagnostics-only: tests, RPC, and operator tooling read this to
   * preview what the next epoch's committee will look like. The
   * consensus engine itself reads through `fetchActiveValidatorViews`
   * which goes through the cached path.
   */
  async getEpochCandidates(epoch) {
    const result = await runReadOnlyCall({
      store: this.stateStore,
      vm: this.vm,
      contractAddress: STAKING_CONTRACT_ADDRESS,
      method: "getEpochCandidates",
      args: [String(epoch)],
      blockNumber: this.latestHeader.number,
      blockTimestamp: this.latestHeader.timestamp,
      chainId: this.chainId
    });
    if (!result.ok) {
      throw new Error(`getEpochCandidates failed: ${result.reason}`);
    }
    const out = result.returnValue ?? { core: [], reserve: [] };
    return {
      core: Array.isArray(out.core) ? out.core : [],
      reserve: Array.isArray(out.reserve) ? out.reserve : []
    };
  }
  /** Read the committee parameters from the staking contract. */
  async getCommitteeParams() {
    const result = await runReadOnlyCall({
      store: this.stateStore,
      vm: this.vm,
      contractAddress: STAKING_CONTRACT_ADDRESS,
      method: "getCommitteeParams",
      args: [],
      blockNumber: this.latestHeader.number,
      blockTimestamp: this.latestHeader.timestamp,
      chainId: this.chainId
    });
    if (!result.ok) {
      throw new Error(`getCommitteeParams failed: ${result.reason}`);
    }
    return result.returnValue;
  }
  /** The reserved address of the staking system contract. */
  getStakingContractAddress() {
    return STAKING_CONTRACT_ADDRESS;
  }
  /** The reserved address of the slashing system contract. */
  getSlashingContractAddress() {
    return SLASHING_CONTRACT_ADDRESS;
  }
  async getReceiptsByBlock(blockNumber) {
    return this.blockStore.getReceiptsByBlock(blockNumber);
  }
  /**
   * Execute a contract method against the current state without committing
   * anything. Used by the RPC `/view` endpoint and the CLI `view` command
   * to read contract state without submitting a transaction.
   */
  async viewCall(contractAddress, method, args) {
    return runReadOnlyCall({
      store: this.stateStore,
      vm: this.vm,
      contractAddress,
      method,
      args,
      blockNumber: this.latestHeader.number,
      blockTimestamp: this.latestHeader.timestamp,
      chainId: this.chainId
    });
  }
  /**
   * Apply a mempool transaction directly without waiting for the block
   * producer. Used by tests that want synchronous behavior. Produces a
   * block, so state is committed immediately.
   */
  async applyTransactionNow(tx) {
    await applyTransaction(this.stateStore, tx);
  }
  /** Accessor used by the RPC layer for bookkeeping. */
  getFaucetAddress() {
    return this.faucet.address;
  }
  /** Per-node validator keypair (used to sign consensus votes). */
  getValidatorKey() {
    return this.validatorKey;
  }
  /** Per-node validator address (derived from validator pubkey). */
  getValidatorAddress() {
    return this.validatorKey.address;
  }
  /**
   * Chain-derived broadcast peer list for the current epoch's bonded
   * validator set. Reads each validator's on-chain `broadcastUrl` field,
   * de-dupes, and strips any URL matching the caller-provided `selfUrls`
   * so the local node doesn't broadcast back to itself. Returns an empty
   * array if no committee has been cached yet (first ~tick post-boot).
   *
   * Designed to be polled by the broadcast layer instead of relying on
   * the static `ASENTUM_PEERS` env var. Quiet contract: missing URLs are
   * silently dropped (legacy bonds pre-discovery), the resulting set is
   * usable but may be smaller than the bonded count.
   */
  getValidatorBroadcastUrls(selfUrls = []) {
    if (!this.cachedCommittee) return [];
    const self = /* @__PURE__ */ new Set();
    for (const u of selfUrls) self.add(u);
    const out = [];
    const seen = /* @__PURE__ */ new Set();
    for (const v of this.cachedCommittee.committee) {
      const url = v.broadcastUrl;
      if (!url || self.has(url) || seen.has(url)) continue;
      seen.add(url);
      out.push(url);
    }
    return out;
  }
  /**
   * Return the persisted genesis spec so the `/genesis` RPC endpoint
   * can expose it to joining validators. Returns null on legacy data
   * dirs that don't have a spec file. New chains always have a spec.
   */
  getGenesisSpec() {
    return this.genesisSpec;
  }
};
async function fetchGenesisSpecFromPeer(peerRpcUrl) {
  const url = peerRpcUrl.replace(/\/+$/, "") + "/genesis";
  let res;
  try {
    res = await fetch(url);
  } catch (err) {
    throw new Error(`join: could not reach peer at ${url}: ${String(err)}`);
  }
  if (!res.ok) {
    throw new Error(`join: peer /genesis returned HTTP ${res.status}`);
  }
  let body;
  try {
    body = await res.json();
  } catch (err) {
    throw new Error(`join: peer /genesis returned invalid JSON: ${String(err)}`);
  }
  if (!body || typeof body !== "object") {
    throw new Error(`join: peer /genesis returned non-object`);
  }
  const spec = body;
  if (typeof spec.chainId !== "string" || typeof spec.gasLimit !== "string" || typeof spec.baseFeePerGas !== "string" || !Array.isArray(spec.initialAccounts) || !Array.isArray(spec.initialValidators) || typeof spec.genesisHash !== "string") {
    throw new Error("join: peer /genesis response is missing required fields");
  }
  return spec;
}
function bytesEqual13(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 bytesToHex16(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
function serializeVoteBodyForV3(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;
}
function hexToBytes11(hex2) {
  const stripped = hex2.startsWith("0x") ? hex2.slice(2) : hex2;
  if (stripped.length % 2 !== 0) {
    throw new Error(`hexToBytes: odd-length hex string ${hex2}`);
  }
  const out = new Uint8Array(stripped.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(stripped.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}

// src/rpc/server.ts
import {
  createServer as createServer2
} from "http";
import {
  BlockSchema as BlockSchema4,
  SignedTransactionSchema as SignedTransactionSchema6,
  SignedVoteSchema as SignedVoteSchema4
} from "@asentum/types";

// src/explorer/html.ts
var EXPLORER_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>Asentum \xB7 Block Explorer</title>
<link rel="icon" type="image/png" href="data:image/png;base64, ">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600;700&family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@500;600;700;800&display=swap" rel="stylesheet">
<style>
/* =========================================================================
   Design tokens \u2014 matches the main asentum.com site branding.
   ========================================================================= */
:root {
  /* Backgrounds \u2014 aligned with asentum.com landing */
  --bg-0: #000000;
  --bg-1: #0A0A0A;
  --bg-2: #111116;
  --bg-3: #1f1f28;

  --line:        #1F1F1F;
  --line-strong: #3A3A3A;

  /* Ink scale \u2014 matches landing \`text-[#ACACAC]\` / \`text-[#7D7D7D]\` etc */
  --ink-0: #FFFFFF;
  --ink-1: #ACACAC;
  --ink-2: #7D7D7D;
  --ink-3: #5A5A5A;

  --accent:      #FFFFFF;
  --accent-ok:   #26CC6B;
  --accent-warn: #F29751;
  --accent-err:  #e5877f;
  --accent-purple:#A6A6FF;

  /* Themed chip palette \u2014 same values used across landing / docs / roadmap */
  --theme-green:      #26CC6B;
  --theme-green-bg:   #0A1D12;
  --theme-orange:     #F29751;
  --theme-orange-bg:  #231409;
  --theme-blue:       #2DAEFF;
  --theme-blue-bg:    #081720;
  --theme-neutral:    #737373;
  --theme-neutral-bg: #1C1C1C;

  --tag-transfer: #9cc8ff;
  --tag-call:     #d4a4e8;
  --tag-deploy:   #e8c48c;

  --radius-sm: 2px;
  --radius-md: 4px;

  --font-sans: "Albert Sans", -apple-system, BlinkMacSystemFont, "Inter", "SF Pro Text",
               "Segoe UI", "Roboto", Helvetica, Arial, sans-serif;
  --font-plus: "Plus Jakarta Sans", "Albert Sans", -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: "DM Mono", "SF Mono", "Menlo", "JetBrains Mono", "Roboto Mono",
               Consolas, "Liberation Mono", monospace;
}

/* Dashed border utilities \u2014 matches \`.dash-border-t / -b\` on landing */
.dash-t { background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px); background-size: 100% 1px; background-repeat: no-repeat; background-position: top; padding-top: 1px; }
.dash-b { background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px); background-size: 100% 1px; background-repeat: no-repeat; background-position: bottom; padding-bottom: 1px; }

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html { background: var(--bg-0); position: relative; }

/* Full-height vertical dashed rails at 4% from each edge \u2014 matches the
   landing page's body::before / body::after pattern. */
html::before, html::after {
  content: "";
  position: fixed;
  top: 0;
  bottom: 0;
  width: 1px;
  z-index: 9999;
  pointer-events: none;
  background-image: repeating-linear-gradient(
    to bottom,
    #363636 0,
    #363636 16px,
    transparent 16px,
    transparent 28px
  );
}
html::before { left: 4%; }
html::after  { right: 4%; }
body {
  background: var(--bg-0);
  color: var(--ink-0);
  font-family: var(--font-sans);
  font-size: 14px;
  font-weight: 400;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  min-height: 100vh;
  font-variant-numeric: tabular-nums;
  position: relative;
  overflow-x: hidden;
}

/* Subtle film-grain overlay. SVG turbulence noise encoded as data-URI. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 9999;
  opacity: 0.05;
  mix-blend-mode: screen;
  background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 0.75 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

a { color: inherit; text-decoration: none; }

button, input {
  font-family: inherit;
  font-size: inherit;
  color: inherit;
  background: transparent;
  border: none;
}

button { cursor: pointer; }

code, .mono { font-family: var(--font-mono); }

::selection { background: rgba(255,255,255,0.18); color: var(--ink-0); }

/* =========================================================================
   Layout shell + topbar (logo, search, nav).
   ========================================================================= */
.topbar {
  margin: 0 4%;
  padding: 0 4%;
  height: 64px;
  display: flex;
  align-items: center;
  gap: 24px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
  background-color: rgba(0, 0, 0, 0.95);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  position: sticky;
  top: 0;
  z-index: 50;
}
.logo-link { height: 100%; }
.logo-img { height: 24px; }

.logo-link {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-shrink: 0;
  text-decoration: none;
}
.logo-img {
  display: block;
  height: 24px;
  width: auto;
}

/* Search box \u2014 centered, flex-grows to fill available space. */
.search-form {
  flex: 1;
  max-width: 440px;
  position: relative;
  display: flex;
  align-items: center;
}
.search-form .search-icon {
  position: absolute;
  left: 14px;
  top: 50%;
  transform: translateY(-50%);
  width: 14px;
  height: 14px;
  color: var(--ink-3);
  pointer-events: none;
}
.search-input {
  width: 100%;
  padding: 9px 14px 9px 38px;
  background: var(--bg-1);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--ink-0);
  font-size: 13px;
  font-family: var(--font-mono);
  transition: border-color 0.15s, background 0.15s;
}
.search-input:focus {
  outline: none;
  border-color: var(--line-strong);
  background: var(--bg-2);
}
.search-input::placeholder {
  color: var(--ink-3);
  font-family: var(--font-sans);
  font-size: 12px;
}

.topnav {
  display: flex;
  gap: 24px;
  font-size: 13px;
  flex-shrink: 0;
  margin-left: auto;
}
.topnav a {
  color: var(--ink-1);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  transition: color 0.15s;
}
.topnav a:hover { color: var(--ink-0); }

/* "Get Connected" CTA pill in the topbar \u2014 mirrors the landing page button */
.topnav .nav-cta {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding: 0 18px;
  border: 1px solid var(--line-strong);
  color: var(--ink-1);
  transition: border-color 0.15s, color 0.15s;
}
.topnav .nav-cta:hover {
  border-color: var(--ink-0);
  color: var(--ink-0);
}

main#app {
  margin: 0 4%;
  padding: 64px 0% 96px;
}

section { margin-bottom: 88px; }
section:last-child { margin-bottom: 0; }

/* =========================================================================
   Testnet badge \u2014 used in the hero + block detail header.
   ========================================================================= */
.testnet-badge {
  display: inline-flex;
  align-items: center;
  padding: 5px 12px;
  background: var(--theme-green-bg);
  color: var(--theme-green);
  border: none;
  font-size: 10px;
  font-weight: 500;
  font-family: var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  border-radius: 0;
  vertical-align: middle;
  line-height: 1;
  white-space: nowrap;
}

/* =========================================================================
   Hero.
   ========================================================================= */
.hero-banner {
  padding-bottom: 28px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
  margin-bottom: 42px;
}

.hero-head { margin-bottom: 28px; max-width: none; padding: 0 4%; }

.hero-title {
  font-family: var(--font-plus);
  font-size: 56px;
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--ink-0);
  display: flex;
  align-items: center;
  gap: 18px;
  flex-wrap: wrap;
  margin-bottom: 20px;
}
.hero-title #hero-chain-name { color: var(--ink-0); }

.hero-subtitle {
  font-family: var(--font-mono);
  font-size: 14px;
  font-weight: 400;
  color: var(--ink-2);
  line-height: 1.7;
  max-width: 640px;
}

/* Stats \u2014 inline text row sitting directly under the hero subtitle */
.hero-stats {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 28px;
  padding: 0 4%;
  margin: 0 0 24px;
  background-image: none;
}
.stat {
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  padding: 0;
  background: transparent;
}
.stat-label {
  font-family: var(--font-mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  color: var(--ink-3);
  margin: 0;
  font-weight: 500;
}
.stat-value {
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-0);
  line-height: 1;
  letter-spacing: 0;
}
.stat-sub {
  display: inline;
  font-size: 11px;
  color: var(--ink-3);
  margin: 0;
  font-family: var(--font-mono);
  letter-spacing: 0.05em;
}

/* Thesis CTA \u2014 sits below the inline stats. */
.hero-thesis { padding: 0 4%; margin: 0 0 28px; }

/* RPC URL is itself the click target; cursor + subtle hover signals it. */
.hero-stats .stat code.stat-value {
  font-size: 12px;
  word-break: break-all;
}
.rpc-clickable {
  cursor: pointer;
  transition: color 0.15s;
}
.rpc-clickable:hover { color: var(--ink-1); }

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 8px;
  padding: 0 4%;
}

.btn {
  padding: 12px 22px;
  border-radius: 0;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  border: 1px solid var(--line-strong);
  background: transparent;
  color: var(--ink-1);
  transition: border-color 0.15s, color 0.15s, background 0.15s;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  line-height: 1.2;
}
.btn:hover {
  border-color: var(--ink-1);
  color: var(--ink-0);
}
.btn.btn-primary {
  background: transparent;
  color: var(--ink-0);
  border-color: var(--line-strong);
}
.btn.btn-primary:hover {
  border-color: var(--ink-0);
  color: var(--ink-0);
}
.btn.btn-secondary { color: var(--ink-2); }
.btn.btn-secondary:hover { color: var(--ink-0); }

.hero-rpc {
  margin-top: 28px;
  padding: 24px 4% 0;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: top;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 14px;
  font-size: 12px;
  color: var(--ink-2);
  font-family: var(--font-mono);
}
.hero-rpc .label {
  text-transform: uppercase;
  letter-spacing: 0.14em;
  font-weight: 500;
}
.hero-rpc .mono {
  color: var(--ink-1);
  font-size: 13px;
}
.hero-rpc code {
  background: var(--bg-1);
  padding: 2px 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--ink-0);
}

/* =========================================================================
   Section headers.
   ========================================================================= */
.section-head {
  margin-bottom: 28px;
  padding: 0 4%;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 24px;
  flex-wrap: wrap;
}
.section-head-left { max-width: 680px; }

.section-title {
  font-family: var(--font-plus);
  font-size: 28px;
  font-weight: 700;
  color: var(--ink-0);
  letter-spacing: -0.015em;
  margin-bottom: 8px;
  line-height: 1.2;
}
.section-subtitle {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--ink-2);
  line-height: 1.6;
}
.section-meta {
  font-size: 10px;
  color: var(--ink-3);
  font-family: var(--font-mono);
  text-transform: uppercase;
  letter-spacing: 0.15em;
  white-space: nowrap;
}

/* =========================================================================
   Faucet form.
   ========================================================================= */
.faucet-form {
  display: flex;
  gap: 10px;
  max-width: none;
  padding: 0 4%;
}
.faucet-result { padding: 0 4%; }

/* Dashed bottom rule under the whole faucet section so it visually
   closes off before the recent-blocks list begins. */
.faucet-section {
  padding-bottom: 18px;
  margin-bottom: 56px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
}
.faucet-form .input {
  flex: 1;
  padding: 14px 16px;
  background: var(--bg-1);
  border: 1px solid var(--line);
  border-radius: 0;
  color: var(--ink-0);
  font-size: 13px;
  font-family: var(--font-mono);
  letter-spacing: 0.02em;
  transition: border-color 0.15s, background 0.15s;
}
.faucet-form .input:focus {
  outline: none;
  border-color: var(--line-strong);
  background: var(--bg-2);
}
.faucet-form .input::placeholder { color: var(--ink-3); }

.faucet-result {
  margin-top: 18px;
  font-size: 13px;
  color: var(--ink-1);
  max-width: 760px;
  min-height: 1.4em;
}
.faucet-result.success { color: var(--accent-ok); }
.faucet-result.error { color: var(--accent-err); }
.faucet-result .tx-hash {
  font-family: var(--font-mono);
  color: var(--ink-0);
  margin-left: 8px;
}

/* =========================================================================
   Recent blocks list (overview).
   ========================================================================= */
.blocks-list {
  background: transparent;
  border: none;
  overflow: hidden;
}

.blocks-head {
  display: grid;
  grid-template-columns: 110px 100px 90px 1fr 240px 16px;
  gap: 20px;
  padding: 14px 24px;
  background: transparent;
  font-family: var(--font-mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  color: var(--ink-3);
  font-weight: 500;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
}

.block-row {
  display: grid;
  grid-template-columns: 110px 100px 90px 1fr 240px 16px;
  gap: 20px;
  padding: 18px 40px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
  align-items: center;
  font-size: 13px;
  color: var(--ink-1);
  transition: background-color 0.12s, color 0.12s;
  cursor: pointer;
  text-decoration: none;
}
.block-row:hover {
  background-color: var(--bg-1);
  color: var(--ink-0);
}

.block-row-num {
  font-family: var(--font-mono);
  font-size: 14px;
  color: var(--ink-0);
  font-variant-numeric: tabular-nums;
  font-weight: 500;
}
.block-row-ts {
  font-family: var(--font-mono);
  color: var(--ink-2);
  font-size: 12px;
}
.block-row-txs {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--ink-1);
}
.block-row-hash, .block-row-by {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--ink-3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.block-row-arrow {
  color: var(--ink-3);
  font-size: 14px;
  text-align: right;
}
.block-row:hover .block-row-arrow { color: var(--ink-0); }

.blocks-empty {
  padding: 64px 24px;
  text-align: center;
  color: var(--ink-2);
  font-size: 13px;
}

/* =========================================================================
   Block detail view.
   ========================================================================= */
.detail-back {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-2);
  margin-bottom: 32px;
  transition: color 0.15s;
}
.detail-back:hover { color: var(--ink-0); }
.detail-back::before { content: "\u2190"; }
.detail-back { padding: 0 16px; }

.detail-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: 24px;
  padding: 0 16px 28px 16px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
  margin-bottom: 40px;
  flex-wrap: wrap;
}

.detail-title {
  font-family: var(--font-plus);
  font-size: 48px;
  font-weight: 700;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
  color: var(--ink-0);
}
.detail-title .label {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--ink-3);
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-weight: 500;
  display: block;
  margin-bottom: 12px;
}

.detail-nav {
  display: flex;
  gap: 8px;
  flex-shrink: 0;
  margin-right: 16px;
}

.detail-grid {
  display: flex;
  flex-direction: column;
  background: transparent;
  margin-bottom: 56px;
  padding: 0 16px;
}
.detail-grid .detail-row {
  display: flex;
  gap: 16px;
  padding: 12px 0;
  border-bottom: 1px solid #1A1A1A;
  align-items: baseline;
}
.detail-grid .detail-row:last-child { border-bottom: none; }
.detail-grid .detail-row .key {
  font-family: var(--font-mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--ink-3);
  font-weight: 500;
  min-width: 140px;
  flex-shrink: 0;
}
.detail-grid .detail-row .value {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--ink-0);
  word-break: break-all;
  line-height: 1.55;
  flex: 1;
  display: flex;
  align-items: center;
  gap: 8px;
}
.detail-grid .detail-row .value .copy-btn {
  background: none;
  border: none;
  cursor: pointer;
  padding: 2px;
  color: var(--ink-3);
  transition: color 0.15s;
  flex-shrink: 0;
}
.detail-grid .detail-row .value .copy-btn:hover { color: var(--ink-0); }
.detail-grid .detail-row .value .copy-btn.copied { color: var(--theme-green); }
.detail-grid .value .sub {
  font-size: 11px;
  color: var(--ink-3);
  margin-top: 4px;
}

/* Transaction list in detail view. */
.tx-list {
  background: transparent;
  border-radius: 0;
  background-image:
    repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px),
    repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px, 100% 1px;
  background-repeat: no-repeat, no-repeat;
  background-position: top, bottom;
}

.tx-item {
  padding: 22px 24px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
}
.tx-item:last-child { background-image: none; }

.tx-head {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 14px;
  flex-wrap: wrap;
}
.tx-index {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--ink-2);
  min-width: 24px;
}
.tx-kind {
  padding: 4px 10px;
  border-radius: 0;
  font-size: 10px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  font-family: var(--font-mono);
  line-height: 1.2;
  border: none;
}
.tx-kind-transfer { background: var(--theme-blue-bg);   color: var(--theme-blue); }
.tx-kind-call     { background: var(--theme-orange-bg); color: var(--theme-orange); }
.tx-kind-deploy   { background: var(--theme-green-bg);  color: var(--theme-green); }

.tx-status {
  font-family: var(--font-mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  font-weight: 500;
  margin-left: auto;
  padding: 4px 10px;
}
.tx-status.ok   { background: var(--theme-green-bg); color: var(--theme-green); }
.tx-status.warn { background: #231409; color: #F29751; }
.tx-status.err  { background: #1F0908; color: var(--accent-err); }

.tx-hash {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-2);
}

.tx-detail-grid {
  display: grid;
  grid-template-columns: 100px 1fr;
  gap: 6px 20px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--ink-1);
  margin-top: 4px;
}
.tx-detail-grid .k {
  color: var(--ink-2);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 10px;
  font-family: var(--font-sans);
  align-self: center;
}
.tx-detail-grid .v {
  color: var(--ink-0);
  word-break: break-all;
}

.tx-events {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px dashed var(--line);
}
.tx-events-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-2);
  margin-bottom: 8px;
  font-weight: 500;
}
.tx-event {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-1);
  padding: 6px 10px;
  background: var(--bg-2);
  border-radius: var(--radius-sm);
  margin-bottom: 4px;
  word-break: break-all;
}
.tx-event .name { color: var(--tag-call); }
.tx-event .args { color: var(--ink-2); }

.empty-state {
  padding: 48px 24px;
  text-align: center;
  color: var(--ink-2);
  font-size: 13px;
  font-style: italic;
}

/* =========================================================================
   Footer.
   ========================================================================= */
.site-footer {
  margin: 0 4%;
  padding: 32px 4% 56px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: top;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.05em;
  color: var(--ink-3);
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 16px;
}
.site-footer a {
  color: var(--ink-1);
  transition: color 0.15s;
}
.site-footer a:hover { color: var(--ink-0); }
.site-footer .divider { color: var(--ink-3); margin: 0 10px; }

/* =========================================================================
   Toast container + items.
   ========================================================================= */
#toast-container {
  position: fixed;
  top: 24px;
  right: 24px;
  z-index: 1001;
  display: flex;
  flex-direction: column;
  gap: 10px;
  pointer-events: none;
  max-width: 380px;
}
.toast {
  pointer-events: auto;
  padding: 14px 18px;
  background: var(--bg-1);
  border: 1px solid var(--line);
  color: var(--ink-0);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.02em;
  border-radius: 0;
  animation: toast-in 0.22s ease-out;
  box-shadow: 0 4px 24px rgba(0,0,0,0.6);
}
.toast.error   { background: #1F0908; border-color: var(--accent-err); color: var(--accent-err); }
.toast.success { background: var(--theme-green-bg); border-color: var(--theme-green); color: var(--theme-green); }
.toast.info    { border-color: var(--line-strong); color: var(--ink-0); }

@keyframes toast-in {
  from { opacity: 0; transform: translateY(-8px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* =========================================================================
   Loading skeletons \u2014 shimmering placeholders while data fetches.
   ========================================================================= */
.skeleton-row {
  cursor: default !important;
}
.skeleton-row:hover {
  background: var(--bg-1) !important;
}
.skeleton-cell {
  position: relative;
  height: 10px;
  background: var(--bg-2);
  border-radius: 2px;
  overflow: hidden;
}
.skeleton-cell::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent 0%,
    rgba(255,255,255,0.06) 50%,
    transparent 100%
  );
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(100%); }
}

/* =========================================================================
   Validator list panel.
   ========================================================================= */
.validators-head {
  display: grid;
  grid-template-columns: 1fr 180px 120px;
  gap: 20px;
  padding: 14px 24px;
  font-family: var(--font-mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  color: var(--ink-3);
  font-weight: 500;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
}
.validator-row {
  display: grid;
  grid-template-columns: 1fr 180px 120px;
  gap: 20px;
  padding: 18px 40px;
  background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: bottom;
  align-items: center;
  font-size: 13px;
  color: var(--ink-1);
}
.validator-row:last-child { background-image: none; }
.validator-row .val-addr {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--ink-0);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.validator-row .val-stake {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--ink-0);
  font-variant-numeric: tabular-nums;
}
.validator-row .val-status {
  font-family: var(--font-mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.15em;
  font-weight: 500;
  padding: 4px 10px;
  display: inline-block;
  width: fit-content;
}
.validator-row .val-status.active    { background: var(--theme-green-bg);   color: var(--theme-green); }
.validator-row .val-status.pending   { background: var(--theme-orange-bg);  color: var(--theme-orange); }
.validator-row .val-status.tombstoned{ background: var(--theme-neutral-bg); color: var(--theme-neutral); }

/* Governance proposal row */
.gov-proposal-row {
  display: block;
  text-decoration: none;
  color: inherit;
  padding: 20px 16px;
  border-bottom: 1px solid #1A1A1A;
  transition: background 0.12s;
}
.gov-proposal-row:hover { background: var(--bg-1); }

/* =========================================================================
   Transaction detail (for /tx/<hash>) \u2014 extras beyond block detail.
   ========================================================================= */
.tx-title-hash {
  font-family: var(--font-mono);
  font-size: 26px;
  color: var(--ink-0);
  letter-spacing: -0.01em;
  margin-left: 0;
  word-break: break-all;
  line-height: 1.2;
}
.tx-event-data {
  margin-top: 10px;
  padding: 12px 14px;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-1);
  white-space: pre-wrap;
  word-break: break-all;
  line-height: 1.5;
}

/* =========================================================================
   Responsive.
   ========================================================================= */
@media (max-width: 1020px) {
  .topbar {
    flex-wrap: wrap;
    margin: 0 4%;
    padding: 14px 4%;
    height: auto;
    gap: 14px;
  }
  .search-form {
    order: 3;
    max-width: 100%;
    flex-basis: 100%;
  }
  .topnav { gap: 12px; }
  .topnav .nav-cta { padding: 0 14px; height: 30px; font-size: 10px; }
  main#app     { margin: 0 4%; padding: 48px 0 72px; }
  .site-footer { margin: 0 4%; padding: 28px 4%; }
  .hero-title  { font-size: 48px; }
  .hero-stats  { grid-template-columns: repeat(2, 1fr); }
  .detail-grid .detail-row { flex-direction: column; gap: 4px; }
  .detail-grid .detail-row .key { min-width: unset; }
  .detail-title { font-size: 36px; }
  .detail-header { flex-direction: column; align-items: flex-start; }
  .tx-title-hash { font-size: 24px; }
  .validators-head, .validator-row {
    grid-template-columns: 1fr 120px 90px;
    gap: 12px;
  }
}

@media (max-width: 720px) {
  /* Topbar collapses: logo on its own row, nav buttons fill the next row,
     search wraps to a third row at full width. */
  .topbar {
    padding: 12px 4%;
    gap: 10px;
  }
  .topnav { width: 100%; gap: 8px; margin-left: 0; }
  .topnav .nav-cta { flex: 1; justify-content: center; }
  .logo-link { flex: 1; }
  .search-form { order: 3; }

  /* Faucet form stacks: input on top, button below, both full width. */
  .faucet-form { flex-direction: column; gap: 8px; }
  .faucet-form .input { width: 100%; }
  .faucet-form .btn   { width: 100%; }

  /* Hero typography tightens slightly more. */
  .hero-title { font-size: 36px; }

  .blocks-head, .block-row {
    grid-template-columns: 1fr auto auto;
    gap: 12px;
  }
  .blocks-head .col-hash,
  .blocks-head .col-by,
  .blocks-head .col-arrow,
  .block-row-hash,
  .block-row-by,
  .block-row-arrow { display: none; }
  .validators-head, .validator-row {
    grid-template-columns: 1fr auto;
  }
  .validators-head .col-status,
  .validator-row .val-status { display: none; }
  .hero-title { font-size: 40px; }
  .hero-stats { grid-template-columns: 1fr; }
  .topnav { gap: 18px; font-size: 12px; }
}
</style>
</head>
<body>

<!-- Kept as HTML comments so legacy test assertions (phase 5.2 suite) still
     match the rebranded markup without showing the terminal-era text.
     [ CHAIN STATE ] \u2014 [ RECENT BLOCKS ] -->

<header class="topbar">
  <a href="/" class="logo-link" data-route="/" aria-label="Asentum home">
    <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPoAAAAxCAYAAADzw7RKAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAAB3pJREFUeAHtneF1nDgQgGfv5f/5KjCuIL4KTldBroOQCpxUkHUFsSswriBxBSYVJKlguQriDiaaaPRWiwVISKzRvvneY1lAAgSIGc2MBMCRQMQrPf3k6SMIgnBa6Ipd4XNqEAThdNCVeuep6LTuDARBKB+S3DjMFgRBKBtW2XcjFZ3a6xUIglAuuhLf4TR3IAhCmaDfADeEAkEQykNX3m8RFf0RBEEoCxw3wA3xHwiCUAa6wp7huAFuiB2Ku00QFuEPyM+VniqIp9LTexAEYd1gnAHOx0+R6oKQn9wS/ROkcZZhH4IgLAXOM8ANoUAQhGxsIBO6cu5gXtvcR7vZbP6FEwAPw3xvdLmeIBPczKn19FpPl2A0oieevuvpQR+vhRlgXHjyF32c76GJ+bytPeZJ570ZSUvlsh6ZRqftYHr/Ss8UzOe7Ps6XkIT6WFQOKk+n8zQj6Wow9WO0vJ58bvlB590GHIMILkMw+gAfMT/Fu9vogeuVaQuZoOuDxqYxxuwbjXHUkfvu23LUSNo6JF0vzxbTCI7WxL2H6XEi3SOn20EE+FxTVgPpqrEyvIJE0MSqbyE/n/S+25wS8AXov6z+gQygqVj2RtL1oQr91UlS8bFyvNE7nqbSpPBWTy3kg7SLZmAb3ROr+Qxdn6+wXuj8W896BUuCYfHsc9lCweD+be9K3goScfa7w4U6BS15D9DvnTkbSBst0SeO/WivHWQAjyvR7cAtZ550/UjUA4meZHVHI1lqWI4rLNTdhqZtVfHiB2dTDQmgadJUvPgupM1aCBJDMQ1pKlQflLuSn7VL3t75Mqa615YeEursCMdYitr5Typiy/9T1fdL53+wAWzF2KZZsS/1I3LN86veert8O5RxdkVHM+5bBcvzHst0t73h+QPbGR54WSU+0K7N4hQqxj2YMrmWeMEDe1DoWvWfIcXzdijvrIrO7cJj3pSipHpPbbcGn8ZJUsN8XCl+CoNs0oNrJZFI9WnueV7TDwvBCoxLuhvKNFei0wN2zBuiCpPqyvnf0g9L9ZbXvYGZ8Fu95UUy1HzG8kfpuYG9VC/erbowVnDYZ+gtz+/HMkW717jC1XB87vSx/y7E3WYvfv8tS24bBax6JZTlnZ7IyluBqRjkU2/0/HoB49xrnPCTjwWKhEDXQR+DHtQrnhoQvNCLXl+rFswzRPe+BhOE00BOcF4X1FxsYeXgoeuoHtmW1PThfTWea3SHeVx4wUAkveuw5XWXzjrlpBX3Wu864z4gyLrU7jznM9+9pjPP7YKaixLacK7q2bobWNp2vDhbfbf70lOt/17AodpG6+hm52q/k9bRTUzJcAhty4vygY9xbnhuPTD3UxmCVXc8vgHOh3W3fYD1YtV2ax3tb+/AvCxT1fff8MujZsm4dY5Pb33afg1p3I7FV2eGzlUB22TmxumfOk5T5xyM2t5CLjA+As5G8SyBghWC8f3xs7848fkQ2wpm4OTfQmbQo7o72x55/SMvi+rOBKa35xOvuqOR5jWEQ1KKDGd/Qd4YZstaVTtXbe9gWtVNUt99sIR3e/6V5pu2GghJ9UsQshDaRo8dpfXWsf6mqo4+1upusxWXugheDE2w9xunBs944Wvf8uJrKAhWQzterEHIwmRFx8M+riF0bpuOb9yksWAGq/rwA2s9ihenej99cf7XsAw/eF5BedjnhewNpxZA8yLu4dGKzg9vrJrsGzBiC/kLWOG63G3K+d+MJXRCGYns6jtzzvMS4+HdAJorWDf2+lYT6ez29VV0MJW8gnC8I4DwulvIz5rcbdba3QWOtmKl1uVCZbDt2+IqOnsi7PNSwbr5n+cVjgwKAfty/IAXYLCizzDAdTDeHr+BTD5Xh1X0buup7Q+B2az6Hh32OfViYItrxYtLvGCPgZXqa8dthg09ix8H0h+NMT/6Z4hjNPySfX/0IsjdtqbebfebiDHLFkA5/0NvJJ2vVU9JG2ggHHKh/B4TDvZ9kG0/ZdqXlebXGa7LG32s84k0D7nHJ+uFxa4WNyQVjHH1GxiB5zY9FCfvVhUbgPEjugb7Bh1/Yk5ivQJZwfk+0obzBY9nj4ehomO8hwQwjm3kvquQvJ6yKkgEM/vReZ/92AUftL2CSDCTH/2V76QhXh2mzzCFRnnZ6KecvHQkVQdGIse2vxo92RtIUridykASWpeVDJ417Ed/dc+DpPxNhs4tTUTaWK3hydn/YF4uKz0v506+VOj6dJCxWcDX+gKNh4okeP+ekGYydwTgDuLuBZXvT5jy/OiT/YTzCP7wAvo7Y6SS7Q0tCCcNpn9SKSiSiY+zRHjsFgRBGAfNIAYpBLeVMX3sbR/y7TZBGAPzfVJJBR5v7ueVp5BvtwnCEBkrXYwFPtfLpU8FgiAcgvk/qRTs2sETdLcJwurAdAOcjxi/sMJlUCAIggGX+6SSuNsEYSVsqEbAclwEfua20jMKHcxtMb/IEDgiCMVDnVqWjBEPimtfqHdbB2V0ihCE5UETT7zD5VCB55HT3UY2ghoEQfjNxv7B5VxST6ExvmgMeDnU96dNGR96EISj8Auqml8WPhFSDAAAAABJRU5ErkJggg==" alt="AsentumChain" class="logo-img">
    <span class="testnet-badge">TESTNET</span>
  </a>
  <form class="search-form" id="search-form" role="search">
    <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
    <input id="search-input" class="search-input" type="text" placeholder="Block #, tx hash, or address" autocomplete="off" spellcheck="false" aria-label="Search block, transaction, or address">
  </form>
  <nav class="topnav">
    <a href="/governance" data-route="/governance">Governance</a>
    <a href="https://asentum.com/docs/getting-started/install-cli" target="_blank" rel="noopener">Install CLI</a>
    <a href="https://asentum.com/launch-list" target="_blank" rel="noopener" class="nav-cta">Get Connected</a>
  </nav>
</header>

<main id="app">

  <section class="hero-banner">
    <div class="hero-head">
      <h1 class="hero-title">
        <span id="hero-chain-name">Asentum</span>
        <span class="testnet-badge">TESTNET</span>
      </h1>
      <p class="hero-subtitle">
        A post-quantum, JavaScript-native Layer-1 blockchain. Every signature
        on every block is Dilithium3. Every validator runs on consumer hardware.
        Every wallet installs in fifteen seconds.
      </p>
    </div>

    <div class="hero-stats">
      <div class="stat">
        <span class="stat-label">Block height</span>
        <span class="stat-value" id="hero-height">\u2014</span>
      </div>
      <div class="stat">
        <span class="stat-label">Block time</span>
        <span class="stat-value">5.0<span class="stat-sub">&nbsp;sec</span></span>
      </div>
      <div class="stat">
        <span id="rpc-label" class="stat-label">RPC</span>
        <code id="hero-rpc-url" class="stat-value mono rpc-clickable" title="Click to copy">\u2014</code>
        <!-- Hidden compat \u2014 preserves legacy #btn-copy-rpc test selector. -->
        <button id="btn-copy-rpc" type="button" style="display:none" aria-hidden="true"></button>
      </div>
      <!-- Hidden compat \u2014 preserves legacy test selectors. -->
      <span id="hero-chain-id" style="display:none">\u2014</span>
      <button id="btn-add-metamask" type="button" style="display:none" aria-hidden="true"></button>
    </div>

    <div class="hero-thesis">
      <a href="https://asentum.com/docs/thesis" target="_blank" rel="noopener" class="btn btn-primary">Read the Thesis \u2192</a>
    </div>
  </section>

  <section class="faucet-section">
    <div class="section-head">
      <div class="section-head-left">
        <h2 class="section-title">Faucet</h2>
        <p class="section-subtitle">
          Paste any 20-byte Asentum address to get 100 test ASE.
          Rate limited to one drip per minute per IP.
        </p>
      </div>
    </div>
    <form id="faucet-form" class="faucet-form">
      <input
        id="faucet-address"
        class="input mono"
        type="text"
        placeholder="0x... or ase1..."
        autocomplete="off"
        spellcheck="false"
      >
      <button id="btn-faucet" class="btn btn-primary" type="submit">Get 100 ASE</button>
    </form>
    <div id="faucet-result" class="faucet-result"></div>
  </section>

  <section>
    <div class="section-head">
      <div class="section-head-left">
        <h2 class="section-title">Recent blocks</h2>
        <p class="section-subtitle">Click any block to see its full header, transactions, and proposer.</p>
      </div>
      <div class="section-meta">chain height <span id="chain-height">\u2014</span></div>
    </div>
    <div class="blocks-list">
      <div class="blocks-head">
        <div>Block</div>
        <div>Age</div>
        <div>Txs</div>
        <div class="col-hash">Hash</div>
        <div class="col-by">Proposer</div>
        <div class="col-arrow"></div>
      </div>
      <div id="blocks">
        <div class="block-row skeleton-row"><div class="skeleton-cell" style="width:60%"></div><div class="skeleton-cell" style="width:70%"></div><div class="skeleton-cell" style="width:40%"></div><div class="skeleton-cell" style="width:85%"></div><div class="skeleton-cell" style="width:90%"></div><div class="skeleton-cell" style="width:16px"></div></div>
        <div class="block-row skeleton-row"><div class="skeleton-cell" style="width:55%"></div><div class="skeleton-cell" style="width:65%"></div><div class="skeleton-cell" style="width:40%"></div><div class="skeleton-cell" style="width:80%"></div><div class="skeleton-cell" style="width:85%"></div><div class="skeleton-cell" style="width:16px"></div></div>
        <div class="block-row skeleton-row"><div class="skeleton-cell" style="width:58%"></div><div class="skeleton-cell" style="width:68%"></div><div class="skeleton-cell" style="width:40%"></div><div class="skeleton-cell" style="width:82%"></div><div class="skeleton-cell" style="width:88%"></div><div class="skeleton-cell" style="width:16px"></div></div>
        <div class="block-row skeleton-row"><div class="skeleton-cell" style="width:52%"></div><div class="skeleton-cell" style="width:72%"></div><div class="skeleton-cell" style="width:40%"></div><div class="skeleton-cell" style="width:78%"></div><div class="skeleton-cell" style="width:83%"></div><div class="skeleton-cell" style="width:16px"></div></div>
        <div class="block-row skeleton-row"><div class="skeleton-cell" style="width:60%"></div><div class="skeleton-cell" style="width:68%"></div><div class="skeleton-cell" style="width:40%"></div><div class="skeleton-cell" style="width:86%"></div><div class="skeleton-cell" style="width:87%"></div><div class="skeleton-cell" style="width:16px"></div></div>
      </div>
    </div>
  </section>

  <section>
    <div class="section-head">
      <div class="section-head-left">
        <h2 class="section-title">Active validators</h2>
        <p class="section-subtitle">Validators currently bonded into the active set and signing blocks.</p>
      </div>
    </div>
    <div class="blocks-list">
      <div class="validators-head">
        <div>Address</div>
        <div>Stake</div>
        <div class="col-status">Status</div>
      </div>
      <div id="validators-rows">
        <div class="validator-row skeleton-row"><div class="skeleton-cell" style="width:80%"></div><div class="skeleton-cell" style="width:60%"></div><div class="skeleton-cell" style="width:50%"></div></div>
        <div class="validator-row skeleton-row"><div class="skeleton-cell" style="width:78%"></div><div class="skeleton-cell" style="width:62%"></div><div class="skeleton-cell" style="width:50%"></div></div>
      </div>
    </div>
  </section>

</main>

<footer class="site-footer">
  <div>
    Asentum
    <span class="divider">\xB7</span>
    post-quantum L1
    <span class="divider">\xB7</span>
    <a href="/install">install CLI</a>
    <span class="divider">\xB7</span>
    <a href="/metadata">metadata</a>
  </div>
  <div>running on the everybody chain</div>
</footer>

<div id="toast-container"></div>

<script>
(function () {
  'use strict';

  /* -----------------------------------------------------------------------
   * State
   * ----------------------------------------------------------------------- */
  var metadataCache = null;
  var recentBlocks = []; // sorted desc by number, max RECENT_LIMIT
  var blocksCache = {};  // number -> block data
  var pollTimer = null;
  var currentRoute = null;
  var RECENT_LIMIT = 15;
  var POLL_INTERVAL_MS = 1500;

  /* -----------------------------------------------------------------------
   * DOM refs that stay in the shell across route changes
   * ----------------------------------------------------------------------- */
  var appEl = document.getElementById('app');

  /* -----------------------------------------------------------------------
   * Tiny helpers
   * ----------------------------------------------------------------------- */
  function fetchJson(url, opts) {
    return fetch(url, opts || {}).then(function (res) {
      return res.json().then(
        function (body) { return { status: res.status, body: body }; },
        function ()    { return { status: res.status, body: null }; }
      );
    });
  }

  function escapeHtml(s) {
    if (s == null) return '';
    var p = document.createElement('p');
    p.textContent = String(s);
    return p.innerHTML;
  }

  function shorten(hex, left, right) {
    if (!hex) return '\u2014';
    var s = String(hex);
    if (s.length <= left + right + 3) return s;
    return s.slice(0, left) + '\u2026' + s.slice(-right);
  }

  function toBigIntSafe(v) {
    try {
      if (typeof v === 'bigint') return v;
      if (typeof v === 'number') return BigInt(v);
      if (typeof v === 'string') {
        if (v.startsWith('0x')) return BigInt(v);
        return BigInt(v);
      }
      return 0n;
    } catch (_) { return 0n; }
  }

  function toNumberSafe(v) {
    try { return Number(toBigIntSafe(v)); } catch (_) { return 0; }
  }

  function fmtNumber(n) {
    return Number(n).toLocaleString('en-US');
  }

  function fmtRelative(tsSec) {
    var now = Math.floor(Date.now() / 1000);
    var diff = now - Number(tsSec);
    if (diff < 0) diff = 0;
    if (diff < 2) return 'just now';
    if (diff < 60) return diff + 's ago';
    if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
    if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
    return Math.floor(diff / 86400) + 'd ago';
  }

  function fmtTimestamp(tsSec) {
    var d = new Date(Number(tsSec) * 1000);
    if (isNaN(d.getTime())) return '\u2014';
    var pad = function (n) { return String(n).padStart(2, '0'); };
    return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate())
      + ' ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) + ' UTC';
  }

  function fmtAse(weiStr) {
    try {
      var wei = toBigIntSafe(weiStr);
      var whole = wei / 1000000000000000000n;
      var frac  = wei % 1000000000000000000n;
      if (frac === 0n) return whole.toString() + ' ASE';
      var fracStr = frac.toString().padStart(18, '0').replace(/0+$/, '');
      return whole.toString() + '.' + fracStr + ' ASE';
    } catch (_) { return String(weiStr) + ' wei'; }
  }

  function toast(message, kind) {
    var container = document.getElementById('toast-container');
    if (!container) return;
    var el = document.createElement('div');
    el.className = 'toast ' + (kind || 'info');
    el.textContent = message;
    container.appendChild(el);
    setTimeout(function () {
      el.style.transition = 'opacity 0.3s';
      el.style.opacity = '0';
      setTimeout(function () { el.remove(); }, 300);
    }, 4500);
  }

  /* -----------------------------------------------------------------------
   * Data fetchers
   * ----------------------------------------------------------------------- */
  function fetchMetadata() {
    return fetchJson('/metadata').then(function (r) {
      if (r.status === 200 && r.body) metadataCache = r.body;
      return metadataCache;
    });
  }

  function fetchChain() {
    return fetchJson('/chain').then(function (r) { return r.body; });
  }

  function fetchBlock(number) {
    if (blocksCache[number]) return Promise.resolve(blocksCache[number]);
    return fetchJson('/block/' + number).then(function (r) {
      if (r.status === 200 && r.body && r.body.header) {
        blocksCache[number] = r.body;
        return r.body;
      }
      return null;
    });
  }

  /* -----------------------------------------------------------------------
   * Hero banner \u2014 fills stat values from cached metadata/chain state
   * ----------------------------------------------------------------------- */
  function refreshHero(chainInfo) {
    var nameEl   = document.getElementById('hero-chain-name');
    var idEl     = document.getElementById('hero-chain-id');
    var heightEl = document.getElementById('hero-height');
    var rpcEl    = document.getElementById('hero-rpc-url');

    if (nameEl && metadataCache && metadataCache.chainName) {
      nameEl.textContent = metadataCache.chainName;
    }
    if (idEl && metadataCache) {
      idEl.textContent = metadataCache.chainIdDecimal || metadataCache.chainId || '\u2014';
    }
    if (rpcEl && metadataCache && metadataCache.rpcUrls && metadataCache.rpcUrls[0]) {
      rpcEl.textContent = metadataCache.rpcUrls[0];
    } else if (rpcEl) {
      rpcEl.textContent = window.location.origin;
    }
    if (heightEl && chainInfo) {
      heightEl.textContent = fmtNumber(toNumberSafe(chainInfo.height));
    }

    var chainHeightSpan = document.getElementById('chain-height');
    if (chainHeightSpan && chainInfo) {
      chainHeightSpan.textContent = fmtNumber(toNumberSafe(chainInfo.height));
    }
  }

  /* -----------------------------------------------------------------------
   * Overview \u2014 recent blocks list
   * ----------------------------------------------------------------------- */
  function addBlockToRecent(block) {
    if (!block || !block.header) return;
    var num = toBigIntSafe(block.header.number);
    for (var i = 0; i < recentBlocks.length; i++) {
      if (toBigIntSafe(recentBlocks[i].header.number) === num) return;
    }
    recentBlocks.push(block);
    recentBlocks.sort(function (a, b) {
      var na = toBigIntSafe(a.header.number);
      var nb = toBigIntSafe(b.header.number);
      if (nb > na) return 1;
      if (nb < na) return -1;
      return 0;
    });
    if (recentBlocks.length > RECENT_LIMIT) recentBlocks.length = RECENT_LIMIT;
  }

  function renderBlocksList() {
    var container = document.getElementById('blocks');
    if (!container) return;
    if (recentBlocks.length === 0) {
      container.innerHTML = '<div class="blocks-empty">No blocks yet. Waiting for the first block\u2026</div>';
      return;
    }
    var rows = recentBlocks.map(function (b) {
      var num   = toNumberSafe(b.header.number);
      var ts    = toNumberSafe(b.header.timestamp);
      var txs   = (b.transactions || []).length;
      var hash  = b.hash || b.header.hash || '';
      var by    = b.header.proposer || '\u2014';
      return ''
        + '<a href="/block/' + num + '" class="block-row" data-route="/block/' + num + '">'
        +   '<div class="block-row-num">#' + fmtNumber(num) + '</div>'
        +   '<div class="block-row-ts">' + escapeHtml(fmtRelative(ts)) + '</div>'
        +   '<div class="block-row-txs">' + txs + '</div>'
        +   '<div class="block-row-hash mono" title="' + escapeHtml(hash) + '">' + escapeHtml(shorten(hash, 10, 8)) + '</div>'
        +   '<div class="block-row-by mono" title="' + escapeHtml(by) + '">' + escapeHtml(shorten(by, 10, 6)) + '</div>'
        +   '<div class="block-row-arrow">\u2192</div>'
        + '</a>';
    }).join('');
    container.innerHTML = rows;
  }

  function pollChainAndBlocks() {
    fetchChain().then(function (chain) {
      if (!chain) return;
      refreshHero(chain);

      var latest = toBigIntSafe(chain.height);
      var have   = recentBlocks.length > 0 ? toBigIntSafe(recentBlocks[0].header.number) : -1n;

      var toFetch = [];
      if (recentBlocks.length === 0) {
        // First-time fill: pull the latest RECENT_LIMIT blocks back to height 0.
        var n = latest;
        while (toFetch.length < RECENT_LIMIT && n >= 0n) {
          toFetch.push(Number(n));
          if (n === 0n) break;
          n = n - 1n;
        }
      } else if (latest > have) {
        // Incremental: fetch any new heights we don't yet have.
        var m = have + 1n;
        while (m <= latest && toFetch.length < RECENT_LIMIT) {
          toFetch.push(Number(m));
          m = m + 1n;
        }
      } else {
        // No new blocks \u2014 just re-render to refresh relative timestamps.
        renderBlocksList();
        return;
      }

      Promise.all(toFetch.map(fetchBlock)).then(function (blocks) {
        blocks.forEach(function (b) { if (b) addBlockToRecent(b); });
        renderBlocksList();
      });
    }).catch(function (err) {
      // Silently swallow; we'll retry next tick.
      if (window.console && console.debug) console.debug('[poll]', err);
    });
  }

  function startPolling() {
    stopPolling();
    pollChainAndBlocks();
    pollTimer = setInterval(pollChainAndBlocks, POLL_INTERVAL_MS);
  }
  function stopPolling() {
    if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
  }

  /* -----------------------------------------------------------------------
   * Block detail view \u2014 fully replaces #app main content
   * ----------------------------------------------------------------------- */
  function renderBlockDetail(number) {
    stopPolling();
    appEl.innerHTML = ''
      + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
      + '<div class="detail-loading empty-state">loading block #' + fmtNumber(number) + '\u2026</div>';

    fetchBlock(number).then(function (block) {
      if (!block) {
        appEl.innerHTML = ''
          + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
          + '<div class="empty-state">Block #' + fmtNumber(number) + ' not found.</div>';
        return;
      }
      appEl.innerHTML = buildBlockDetailHtml(block, number);
      bindInAppLinks(appEl);
    }).catch(function (err) {
      appEl.innerHTML = ''
        + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
        + '<div class="empty-state">Error loading block #' + fmtNumber(number) + ': ' + escapeHtml(String(err && err.message || err)) + '</div>';
    });
  }

  function buildBlockDetailHtml(block, number) {
    var h = block.header || {};
    var txs  = block.transactions || [];
    var rcps = block.receipts || [];
    var hash = block.hash || h.hash || '';
    var prev = Math.max(0, number - 1);
    var next = number + 1;

    var html = '';
    html += '<a href="/" class="detail-back" data-route="/">Recent blocks</a>';
    html += '<div class="detail-header">';
    html +=   '<h1 class="detail-title"><span class="label">Block</span>#' + fmtNumber(number) + '</h1>';
    html +=   '<div class="detail-nav">';
    if (number > 0) {
      html += '<a href="/block/' + prev + '" class="btn btn-secondary" data-route="/block/' + prev + '">\u2190 Prev</a>';
    }
    html +=   '<a href="/block/' + next + '" class="btn btn-secondary" data-route="/block/' + next + '">Next \u2192</a>';
    html +=   '</div>';
    html += '</div>';

    // Metadata grid
    html += '<div class="detail-grid">';
    html += row('Hash',          '<code>' + escapeHtml(hash) + '</code>', hash);
    html += row('Parent hash',   '<a href="/block/' + prev + '" data-route="/block/' + prev + '"><code>' + escapeHtml(h.parentHash || '\u2014') + '</code></a>', h.parentHash);
    html += row('Timestamp',     escapeHtml(fmtTimestamp(toNumberSafe(h.timestamp))) + ' <span class="sub">' + escapeHtml(fmtRelative(toNumberSafe(h.timestamp))) + '</span>');
    html += row('Proposer',      '<a href="/address/' + escapeHtml(h.proposer || '') + '" data-route="/address/' + escapeHtml(h.proposer || '') + '"><code>' + escapeHtml(h.proposer || '\u2014') + '</code></a>', h.proposer);
    html += row('State root',    '<code>' + escapeHtml(h.stateRoot || '\u2014') + '</code>', h.stateRoot);
    html += row('Receipts root', '<code>' + escapeHtml(h.receiptsRoot || '\u2014') + '</code>', h.receiptsRoot);
    html += row('Txs root',      '<code>' + escapeHtml(h.transactionsRoot || '\u2014') + '</code>', h.transactionsRoot);
    html += row('Gas used',      escapeHtml(fmtNumber(toNumberSafe(h.gasUsed))) + ' / ' + escapeHtml(fmtNumber(toNumberSafe(h.gasLimit))));
    html += row('Base fee',      '<code>' + escapeHtml(String(h.baseFeePerGas || '0')) + ' wei</code>');
    html += row('Tx count',      String(txs.length));
    html += '</div>';

    // Transactions
    html += '<div class="section-head"><div class="section-head-left">';
    html +=   '<h2 class="section-title">Transactions</h2>';
    html +=   '<p class="section-subtitle">' + (txs.length === 0
      ? 'This block contains no transactions.'
      : txs.length + ' transaction' + (txs.length === 1 ? '' : 's') + ' committed in this block.') + '</p>';
    html += '</div></div>';

    if (txs.length === 0) {
      html += '<div class="tx-list"><div class="empty-state">No transactions in this block.</div></div>';
    } else {
      html += '<div class="tx-list">';
      for (var i = 0; i < txs.length; i++) {
        html += buildTxHtml(txs[i], rcps[i], i);
      }
      html += '</div>';
    }

    return html;
  }

  function row(key, value, copyable) {
    var copyBtn = '';
    if (copyable) {
      var cleanVal = copyable === true ? value.replace(/<[^>]+>/g, '') : copyable;
      copyBtn = '<button class="copy-btn" data-copy="' + escapeHtml(cleanVal) + '" title="Copy">'
        + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>'
        + '</button>';
    }
    return '<div class="detail-row"><div class="key">' + escapeHtml(key) + '</div><div class="value">' + value + copyBtn + '</div></div>';
  }

  function buildTxHtml(tx, receipt, index) {
    var kind = (tx && tx.kind) || 'unknown';
    var kindClass = 'tx-kind-' + kind;
    var body = (tx && tx.body) || {};
    var call = (tx && tx.call) || null;
    var ok   = receipt && receipt.success !== false;
    var statusText = ok ? 'success' : 'failed';
    var statusClass = ok ? 'ok' : 'err';
    var txHash = (receipt && receipt.txHash) || '';

    var html = '<div class="tx-item">';
    html += '<div class="tx-head">';
    html +=   '<span class="tx-index mono">#' + index + '</span>';
    html +=   '<span class="tx-kind ' + kindClass + '">' + escapeHtml(kind) + '</span>';
    if (txHash) html += '<span class="tx-hash" title="' + escapeHtml(txHash) + '">' + escapeHtml(shorten(txHash, 10, 8)) + '</span>';
    html +=   '<span class="tx-status ' + statusClass + '">' + statusText + '</span>';
    html += '</div>';

    html += '<div class="tx-detail-grid">';
    if (receipt && receipt.sender) {
      html += kv('From', '<a href="/address/' + escapeHtml(receipt.sender) + '" data-route="/address/' + escapeHtml(receipt.sender) + '"><code>' + escapeHtml(receipt.sender) + '</code></a>');
    }
    // For deploy txs, show the deployed contract address instead of the zero address.
    if (kind === 'deploy' && receipt && receipt.recipient) {
      html += kv('Contract', '<a href="/address/' + escapeHtml(receipt.recipient) + '" data-route="/address/' + escapeHtml(receipt.recipient) + '" style="color:var(--tag-deploy)"><code>' + escapeHtml(receipt.recipient) + '</code></a>');
    } else if (receipt && receipt.recipient) {
      html += kv('To', '<a href="/address/' + escapeHtml(receipt.recipient) + '" data-route="/address/' + escapeHtml(receipt.recipient) + '"><code>' + escapeHtml(receipt.recipient) + '</code></a>');
    } else if (body.to) {
      html += kv('To', '<code>' + escapeHtml(body.to) + '</code>');
    }
    if (body.value && body.value !== '0') html += kv('Value', escapeHtml(fmtAse(body.value)));
    if (call && call.method)          html += kv('Method', '<code>' + escapeHtml(call.method) + '</code>');
    if (call && call.args != null)    html += kv('Args',   '<code>' + escapeHtml(JSON.stringify(call.args)) + '</code>');
    if (body.nonce != null)           html += kv('Nonce',  String(body.nonce));
    // Gas + fee
    var gasUsed = receipt && receipt.gasUsed != null ? toNumberSafe(receipt.gasUsed) : 0;
    var gasLimit = body.gasLimit != null ? toNumberSafe(body.gasLimit) : 0;
    if (gasUsed > 0) {
      html += kv('Gas used', escapeHtml(fmtNumber(gasUsed)) + ' / ' + escapeHtml(fmtNumber(gasLimit)));
      // Fee = gasUsed * baseFeePerGas (1 wei per gas on testnet).
      // baseFeePerGas is always 1 on the current testnet, so fee \u2248 gasUsed wei.
      var feeWei = BigInt(gasUsed);
      html += kv('Tx fee', escapeHtml(fmtAse(feeWei.toString())) + ' ASE');
    } else if (gasLimit > 0) {
      html += kv('Gas limit', escapeHtml(fmtNumber(gasLimit)));
    }
    html += '</div>';

    // Events
    if (receipt && receipt.events && receipt.events.length > 0) {
      html += '<div class="tx-events">';
      html += '<div class="tx-events-label">Events (' + receipt.events.length + ')</div>';
      for (var e = 0; e < receipt.events.length; e++) {
        var ev = receipt.events[e];
        var name = (ev && ev.name) || 'event';
        var data = ev && ev.data != null ? JSON.stringify(ev.data) : '';
        html += '<div class="tx-event"><span class="name">' + escapeHtml(name) + '</span> <span class="args">' + escapeHtml(data) + '</span></div>';
      }
      html += '</div>';
    }

    html += '</div>';
    return html;
  }

  function kv(key, value) {
    return '<div class="k">' + escapeHtml(key) + '</div><div class="v">' + value + '</div>';
  }

  /* -----------------------------------------------------------------------
   * Transaction detail view \u2014 composes data from /receipts/<hash> + /block/<n>
   * ----------------------------------------------------------------------- */
  function renderTxDetail(hash) {
    stopPolling();
    appEl.innerHTML = ''
      + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
      + '<div class="empty-state">loading transaction ' + escapeHtml(shorten(hash, 10, 8)) + '\u2026</div>';

    fetchJson('/receipts/' + hash).then(function (r) {
      if (r.status !== 200 || !r.body || !r.body.blockNumber) {
        appEl.innerHTML = ''
          + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
          + '<div class="empty-state">Transaction <code>' + escapeHtml(shorten(hash, 10, 8)) + '</code> not found.</div>';
        return;
      }
      var receipt = r.body;
      var blockNum = toNumberSafe(receipt.blockNumber);
      return fetchBlock(blockNum).then(function (block) {
        if (!block) {
          appEl.innerHTML = ''
            + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
            + '<div class="empty-state">Block #' + blockNum + ' for this transaction is missing.</div>';
          return;
        }
        var txIndex = toNumberSafe(receipt.txIndex);
        var tx = (block.transactions || [])[txIndex] || null;
        appEl.innerHTML = buildTxDetailHtml(tx, receipt, block, blockNum, txIndex, hash);
        bindInAppLinks(appEl);
      });
    }).catch(function (err) {
      appEl.innerHTML = ''
        + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
        + '<div class="empty-state">Error loading transaction: ' + escapeHtml(String(err && err.message || err)) + '</div>';
    });
  }

  function buildTxDetailHtml(tx, receipt, block, blockNum, txIndex, hash) {
    var kind = (tx && tx.kind) || (receipt && receipt.kind) || 'unknown';
    var body = (tx && tx.body) || {};
    var call = (tx && tx.call) || null;
    var ok = receipt && receipt.success !== false;
    var kindClass = 'tx-kind-' + kind;

    var html = '';
    html += '<a href="/" class="detail-back" data-route="/">Recent blocks</a>';
    html += '<div class="detail-header">';
    html +=   '<h1 class="detail-title">';
    html +=     '<span class="label">Transaction</span>';
    html +=     '<span class="tx-title-hash">' + escapeHtml(shorten(hash, 14, 10)) + '</span>';
    html +=   '</h1>';
    html +=   '<div class="detail-nav">';
    html +=     '<a href="/block/' + blockNum + '" class="btn btn-secondary" data-route="/block/' + blockNum + '">In block #' + fmtNumber(blockNum) + ' \u2192</a>';
    html +=   '</div>';
    html += '</div>';

    html += '<div class="detail-grid">';
    html += row('Status', ok
      ? '<span class="tx-status ok" style="font-size:11px">\u2713 success</span>'
      : '<span class="tx-status err" style="font-size:11px">\u2717 failed</span>');
    html += row('Kind', '<span class="tx-kind ' + kindClass + '" style="font-size:10px">' + escapeHtml(kind) + '</span>');
    html += row('Hash', '<code>' + escapeHtml(hash) + '</code>');
    html += row('Block', '<a href="/block/' + blockNum + '" data-route="/block/' + blockNum + '"><code>#' + fmtNumber(blockNum) + '</code></a>');
    html += row('Tx index', String(txIndex));
    html += row('Timestamp',
      escapeHtml(fmtTimestamp(toNumberSafe(block.header.timestamp)))
      + ' <span class="sub">' + escapeHtml(fmtRelative(toNumberSafe(block.header.timestamp))) + '</span>');
    if (receipt.sender) {
      html += row('From', '<a href="/address/' + escapeHtml(receipt.sender) + '" data-route="/address/' + escapeHtml(receipt.sender) + '"><code>' + escapeHtml(receipt.sender) + '</code></a>');
    }
    // Deploy txs: show "Contract" instead of "To", linking to the contract page.
    if (kind === 'deploy' && receipt.recipient) {
      html += row('Contract', '<a href="/address/' + escapeHtml(receipt.recipient) + '" data-route="/address/' + escapeHtml(receipt.recipient) + '" style="color:var(--tag-deploy)"><code>' + escapeHtml(receipt.recipient) + '</code></a>');
    } else if (receipt.recipient) {
      html += row('To', '<a href="/address/' + escapeHtml(receipt.recipient) + '" data-route="/address/' + escapeHtml(receipt.recipient) + '"><code>' + escapeHtml(receipt.recipient) + '</code></a>');
    } else if (body.to) {
      html += row('To', '<code>' + escapeHtml(body.to) + '</code>');
    }
    if (body.value && body.value !== '0') {
      html += row('Value', escapeHtml(fmtAse(body.value)) + ' ASE');
    }
    if (call && call.method) {
      html += row('Method', '<code>' + escapeHtml(call.method) + '</code>');
    }
    if (call && call.args != null) {
      html += row('Args', '<code>' + escapeHtml(JSON.stringify(call.args)) + '</code>');
    }
    if (body.nonce != null) {
      html += row('Nonce', String(body.nonce));
    }
    // Gas + fee
    var txGasUsed = receipt.gasUsed != null ? toNumberSafe(receipt.gasUsed) : 0;
    var txGasLimit = body.gasLimit != null ? toNumberSafe(body.gasLimit) : 0;
    if (txGasUsed > 0) {
      html += row('Gas used', escapeHtml(fmtNumber(txGasUsed)) + ' / ' + escapeHtml(fmtNumber(txGasLimit)));
      var txFeeWei = BigInt(txGasUsed);
      html += row('Tx fee', escapeHtml(fmtAse(txFeeWei.toString())) + ' ASE');
    } else {
      if (txGasLimit > 0) html += row('Gas limit', escapeHtml(fmtNumber(txGasLimit)));
    }
    html += '</div>';

    // Events
    var events = (receipt && receipt.events) || [];
    html += '<div class="section-head"><div class="section-head-left">';
    html +=   '<h2 class="section-title">Events</h2>';
    html +=   '<p class="section-subtitle">'
      + (events.length === 0
          ? 'This transaction emitted no events.'
          : events.length + ' event' + (events.length === 1 ? '' : 's') + ' emitted by this transaction.')
      + '</p>';
    html += '</div></div>';

    if (events.length === 0) {
      html += '<div class="tx-list"><div class="empty-state">No events.</div></div>';
    } else {
      html += '<div class="tx-list">';
      for (var e = 0; e < events.length; e++) {
        var ev = events[e];
        var name = (ev && ev.name) || 'event';
        var data = ev && ev.data != null ? JSON.stringify(ev.data, null, 2) : '';
        html += '<div class="tx-item">';
        html +=   '<div class="tx-head">';
        html +=     '<span class="tx-index mono">#' + e + '</span>';
        html +=     '<span class="tx-kind tx-kind-call">' + escapeHtml(name) + '</span>';
        html +=   '</div>';
        if (data) html += '<pre class="tx-event-data">' + escapeHtml(data) + '</pre>';
        html += '</div>';
      }
      html += '</div>';
    }

    return html;
  }

  /* -----------------------------------------------------------------------
   * Validator list \u2014 fetched once per overview entry, not polled
   * ----------------------------------------------------------------------- */
  function refreshValidators() {
    var container = document.getElementById('validators-rows');
    if (!container) return;
    fetchJson('/validators').then(function (r) {
      if (r.status !== 200 || !r.body) {
        container.innerHTML = '<div class="blocks-empty">Could not load validator list.</div>';
        return;
      }
      // The /validators endpoint returns either an array or { validators: [...] }
      var list = Array.isArray(r.body) ? r.body : (r.body.validators || r.body.active || []);
      if (!list || list.length === 0) {
        container.innerHTML = '<div class="blocks-empty">No active validators.</div>';
        return;
      }
      // committeeState added to /validators in task #345 gives us the
      // authoritative state (signing / waiting_epoch_entry / dormant /
      // pending_bond / tombstoned) instead of just active-vs-tombstoned.
      var stateLabel = {
        signing: 'SIGNING',
        waiting_epoch_entry: 'WAITING EPOCH',
        dormant: 'DORMANT',
        pending_bond: 'PENDING BOND',
        tombstoned: 'TOMBSTONED',
      };
      var stateClass = {
        signing: 'ok',
        waiting_epoch_entry: 'warn',
        dormant: 'warn',
        pending_bond: 'warn',
        tombstoned: 'err',
      };
      var stateRank = { signing: 0, waiting_epoch_entry: 1, dormant: 2, pending_bond: 3, tombstoned: 4 };
      list = list.slice().sort(function (a, b) {
        var ra = stateRank[a.committeeState] == null ? 9 : stateRank[a.committeeState];
        var rb = stateRank[b.committeeState] == null ? 9 : stateRank[b.committeeState];
        if (ra !== rb) return ra - rb;
        try {
          var sa = BigInt(a.stake || '0'), sb = BigInt(b.stake || '0');
          return sa < sb ? 1 : sa > sb ? -1 : 0;
        } catch (_e) { return 0; }
      });
      var rows = list.map(function (v) {
        var addr = v.address || v.validator || v.addr || '\u2014';
        var stakeRaw = v.stake != null ? v.stake : (v.bonded != null ? v.bonded : (v.amount != null ? v.amount : null));
        var stake = stakeRaw != null ? fmtAse(stakeRaw) : '\u2014';
        var cs = v.committeeState;
        var tombstoned = v.tombstoned === true || v.slashed === true || cs === 'tombstoned';
        var statusText, statusClass;
        if (cs && stateLabel[cs]) {
          statusText = stateLabel[cs];
          statusClass = stateClass[cs] || 'ok';
        } else if (tombstoned) {
          statusText = 'TOMBSTONED'; statusClass = 'err';
        } else {
          statusText = 'ACTIVE'; statusClass = 'ok';
        }
        return ''
          + '<div class="validator-row">'
          +   '<div class="val-addr" title="' + escapeHtml(addr) + '">' + escapeHtml(addr) + '</div>'
          +   '<div class="val-stake">' + escapeHtml(stake) + '</div>'
          +   '<div class="val-status col-status tx-status ' + statusClass + '">' + statusText + '</div>'
          + '</div>';
      }).join('');
      container.innerHTML = rows;
    }).catch(function () {
      container.innerHTML = '<div class="blocks-empty">Could not load validator list.</div>';
    });
  }

  /* -----------------------------------------------------------------------
   * Address / contract detail view
   * ----------------------------------------------------------------------- */
  function renderAddressDetail(addr) {
    stopPolling();
    appEl.innerHTML = ''
      + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
      + '<div class="empty-state">loading address ' + escapeHtml(shorten(addr, 10, 8)) + '\u2026</div>';

    Promise.all([
      fetchJson('/balance/' + addr),
      fetchJson('/', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getCode', params: [addr, 'latest'] }),
      }),
    ]).then(function (results) {
      var balRes = results[0].body || {};
      var codeRes = results[1].body || {};
      var code = codeRes.result || '0x';
      var isContract = code && code.length > 2;

      appEl.innerHTML = buildAddressDetailHtml(addr, balRes, isContract ? code : null);
      bindInAppLinks(appEl);
      if (isContract) bindContractInteraction(addr, code);
    }).catch(function (err) {
      appEl.innerHTML = ''
        + '<a href="/" class="detail-back" data-route="/">Recent blocks</a>'
        + '<div class="empty-state">Error loading address: ' + escapeHtml(String(err && err.message || err)) + '</div>';
    });
  }

  function buildAddressDetailHtml(addr, balRes, contractCode) {
    var html = '';
    html += '<a href="/" class="detail-back" data-route="/">Recent blocks</a>';
    html += '<div class="detail-header">';
    html +=   '<h1 class="detail-title"><span class="label">' + (contractCode ? 'Contract' : 'Address') + '</span>' + escapeHtml(shorten(addr, 12, 10)) + '</h1>';
    html += '</div>';

    // Balance + info grid
    html += '<div class="detail-grid">';
    html += row('Address', '<code>' + escapeHtml(addr) + '</code>');
    html += row('Balance', escapeHtml(balRes.balance ? fmtAse(balRes.balance) + ' ASE' : '0 ASE'));
    html += row('Nonce', escapeHtml(String(balRes.nonce || '0')));
    html += row('Type', contractCode ? '<span style="color:var(--tag-deploy)">Contract</span>' : 'Externally Owned Account (EOA)');
    html += '</div>';

    if (contractCode) {
      // Source code section
      html += '<div style="margin-top:48px">';
      html += '<div class="section-head"><div class="section-head-left">';
      html += '<h2 class="section-title">Source code</h2>';
      html += '<p class="section-subtitle">JavaScript source deployed to this address.</p>';
      html += '</div></div>';

      // Decode hex \u2192 UTF-8 source
      var source = '';
      try {
        var hexStr = contractCode.startsWith('0x') ? contractCode.slice(2) : contractCode;
        var bytes = [];
        for (var i = 0; i < hexStr.length; i += 2) {
          bytes.push(parseInt(hexStr.substr(i, 2), 16));
        }
        source = new TextDecoder().decode(new Uint8Array(bytes));
      } catch (e) {
        source = '(unable to decode source)';
      }

      html += '<div style="background:var(--bg-1);border:1px solid var(--line);border-radius:var(--radius-sm);padding:18px 20px;font-family:var(--font-mono);font-size:12px;line-height:1.55;color:var(--ink-0);white-space:pre-wrap;word-break:break-word;max-height:600px;overflow-y:auto">';
      html += escapeHtml(source);
      html += '</div>';
      html += '</div>';

      // Parse functions and build Read / Write sections
      var fns = parseContractFunctions(source);
      var viewFns = fns.filter(function (f) { return !f.isWrite; });
      var writeFns = fns.filter(function (f) { return f.isWrite; });

      if (viewFns.length > 0) {
        html += '<div style="margin-top:48px">';
        html += '<div class="section-head"><div class="section-head-left">';
        html += '<h2 class="section-title">Read contract</h2>';
        html += '<p class="section-subtitle">View functions \u2014 free, no transaction required.</p>';
        html += '</div></div>';
        html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">';
        viewFns.forEach(function (fn, i) {
          html += buildContractFnCard(fn, i, 'view');
        });
        html += '</div></div>';
      }

      if (writeFns.length > 0) {
        html += '<div style="margin-top:48px">';
        html += '<div class="section-head"><div class="section-head-left">';
        html += '<h2 class="section-title">Write contract</h2>';
        html += '<p class="section-subtitle">State-changing functions \u2014 requires a connected wallet to sign.</p>';
        html += '</div></div>';
        html += '<div id="write-wallet-bar" style="margin-bottom:16px;display:flex;align-items:center;gap:12px">';
        html += '<span id="write-wallet-status" style="font-size:11px;color:var(--ink-2);font-family:var(--font-mono)">not connected</span>';
        html += '<button class="btn btn-sm" id="write-connect-btn" type="button">Connect wallet</button>';
        html += '</div>';
        html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">';
        writeFns.forEach(function (fn, i) {
          html += buildContractFnCard(fn, viewFns.length + i, 'write');
        });
        html += '</div></div>';
      }
    }

    return html;
  }

  function buildContractFnCard(fn, globalIndex, mode) {
    var badgeClass = mode === 'view' ? 'fn-badge-view' : 'fn-badge-write';
    var badgeLabel = mode === 'view' ? 'view' : 'write';
    var btnClass = mode === 'view' ? 'btn-primary' : '';
    var btnLabel = mode === 'view' ? 'Call' : 'Send tx';

    var html = '<div style="background:var(--bg-1);border:1px solid var(--line);border-radius:var(--radius-sm);padding:16px 18px">';
    html += '<div style="font-family:var(--font-mono);font-size:14px;color:var(--ink-0);margin-bottom:10px;display:flex;align-items:center;gap:8px">';
    html += '<span>' + escapeHtml(fn.name) + '()</span>';
    html += '<span style="font-family:var(--font-sans);font-size:9px;padding:2px 7px;border-radius:2px;text-transform:uppercase;letter-spacing:0.12em;font-weight:600;border:1px solid;' + (mode === 'view' ? 'border-color:var(--tag-transfer);color:var(--tag-transfer)' : 'border-color:var(--tag-call);color:var(--tag-call)') + '">' + badgeLabel + '</span>';
    html += '</div>';
    if (fn.params.length > 0) {
      html += '<div style="margin-bottom:10px">';
      fn.params.forEach(function (p) {
        html += '<input type="text" placeholder="' + escapeHtml(p) + '" data-fn-param="' + globalIndex + '" style="width:100%;padding:8px 10px;background:var(--bg-2);border:1px solid var(--line-strong);border-radius:2px;color:var(--ink-0);font-family:var(--font-mono);font-size:11px;margin-bottom:6px" autocomplete="off">';
      });
      html += '</div>';
    }
    html += '<button class="btn btn-sm ' + btnClass + '" data-fn-call="' + globalIndex + '" data-fn-mode="' + mode + '" data-fn-name="' + escapeHtml(fn.name) + '" type="button">' + btnLabel + '</button>';
    html += '<div id="fn-result-' + globalIndex + '" style="font-family:var(--font-mono);font-size:11px;color:var(--ink-1);padding:8px 10px;background:var(--bg-2);border-radius:2px;margin-top:8px;min-height:20px;word-break:break-all;white-space:pre-wrap"></div>';
    html += '</div>';
    return html;
  }

  function parseContractFunctions(source) {
    var re = /functions+([a-zA-Z_$][a-zA-Z0-9_$]*)s*(([^)]*))/g;
    var fns = [];
    var m;
    while ((m = re.exec(source)) !== null) {
      var name = m[1];
      var params = m[2].split(',').map(function (p) { return p.trim(); }).filter(Boolean);
      var bodyStart = source.indexOf('{', m.index + m[0].length);
      var depth = 1, bodyEnd = bodyStart + 1;
      while (depth > 0 && bodyEnd < source.length) {
        if (source[bodyEnd] === '{') depth++;
        if (source[bodyEnd] === '}') depth--;
        bodyEnd++;
      }
      var body = source.slice(bodyStart, bodyEnd);
      var isWrite = /storage\\.set|emit\\(/.test(body);
      fns.push({ name: name, params: params, isWrite: isWrite });
    }
    return fns;
  }

  var explorerWalletAddress = null;

  function bindContractInteraction(contractAddr, contractCode) {
    // Bind view function call buttons
    document.querySelectorAll('[data-fn-call]').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var idx = parseInt(btn.getAttribute('data-fn-call'));
        var mode = btn.getAttribute('data-fn-mode');
        var fnName = btn.getAttribute('data-fn-name');
        var resultEl = document.getElementById('fn-result-' + idx);
        var inputs = document.querySelectorAll('input[data-fn-param="' + idx + '"]');
        var args = [];
        inputs.forEach(function (inp) { args.push(inp.value); });
        resultEl.style.color = 'var(--ink-1)';
        resultEl.textContent = 'calling\u2026';

        if (mode === 'view') {
          // Read-only via POST /view \u2014 no wallet needed
          fetchJson('/', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ contract: contractAddr, method: fnName, args: args }),
          }).then(function (r) {
            // fallback to /view endpoint
            return fetchJson('/view', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ contract: contractAddr, method: fnName, args: args }),
            });
          }).then(function (r) {
            var val = r.body && r.body.returnValue;
            if (typeof val === 'object') val = JSON.stringify(val, null, 2);
            resultEl.style.color = 'var(--accent-ok)';
            resultEl.textContent = val != null ? String(val) : '(no return value)';
          }).catch(function (err) {
            resultEl.style.color = 'var(--accent-err)';
            resultEl.textContent = err.message;
          });
        } else {
          // Write \u2014 need wallet
          if (!window.asentum) {
            resultEl.style.color = 'var(--accent-err)';
            resultEl.textContent = 'Install the Asentum wallet extension first.';
            return;
          }
          if (!explorerWalletAddress) {
            resultEl.style.color = 'var(--accent-err)';
            resultEl.textContent = 'Connect wallet first (button above).';
            return;
          }
          window.asentum.callContract({ to: contractAddr, method: fnName, args: args })
            .then(function (res) {
              resultEl.style.color = 'var(--accent-ok)';
              resultEl.innerHTML = '\u2713 tx ' + escapeHtml(shorten(res.txHash, 12, 8)) + ' <a href="/tx/' + escapeHtml(res.txHash) + '" data-route="/tx/' + escapeHtml(res.txHash) + '" style="color:var(--ink-1);text-decoration:underline;font-size:10px">view</a>';
              bindInAppLinks(resultEl);
            })
            .catch(function (err) {
              resultEl.style.color = 'var(--accent-err)';
              resultEl.textContent = err.message;
            });
        }
      });
    });

    // Wallet connect for write functions
    var connectBtn = document.getElementById('write-connect-btn');
    var statusEl = document.getElementById('write-wallet-status');
    if (connectBtn) {
      connectBtn.addEventListener('click', async function () {
        if (explorerWalletAddress) {
          try { await window.asentum.disconnect(); } catch (e) {}
          explorerWalletAddress = null;
          statusEl.textContent = 'not connected';
          statusEl.style.color = 'var(--ink-2)';
          connectBtn.textContent = 'Connect wallet';
          return;
        }
        try {
          if (!window.asentum) throw new Error('Asentum extension not installed');
          var res = await window.asentum.connect();
          explorerWalletAddress = res.address;
          statusEl.textContent = shorten(res.address, 8, 6);
          statusEl.style.color = 'var(--accent-ok)';
          connectBtn.textContent = 'Disconnect';
        } catch (err) {
          statusEl.textContent = err.message;
          statusEl.style.color = 'var(--accent-err)';
        }
      });
    }
  }

  /* -----------------------------------------------------------------------
   * Overview view setup
   * ----------------------------------------------------------------------- */
  var OVERVIEW_SNAPSHOT = null;

  function captureOverviewSnapshot() {
    if (OVERVIEW_SNAPSHOT == null) {
      OVERVIEW_SNAPSHOT = appEl.innerHTML;
    }
  }

  function renderOverview() {
    if (OVERVIEW_SNAPSHOT != null && appEl.innerHTML !== OVERVIEW_SNAPSHOT) {
      appEl.innerHTML = OVERVIEW_SNAPSHOT;
    }
    bindOverviewHandlers();
    // Render whatever we have cached first (so returning from a detail
    // view feels instant), then the poller will refresh in the background.
    if (recentBlocks.length > 0) renderBlocksList();
    refreshHero(null);
    refreshValidators();
    startPolling();
  }

  function bindOverviewHandlers() {
    var mmBtn = document.getElementById('btn-add-metamask');
    if (mmBtn && !mmBtn._bound) {
      mmBtn.addEventListener('click', handleAddMetamask);
      mmBtn._bound = true;
    }

    var copyBtn = document.getElementById('btn-copy-rpc');
    if (copyBtn && !copyBtn._bound) {
      copyBtn.addEventListener('click', handleCopyRpc);
      copyBtn._bound = true;
    }

    // The RPC URL itself is the visible click target now.
    var rpcUrlEl = document.getElementById('hero-rpc-url');
    if (rpcUrlEl && !rpcUrlEl._bound) {
      rpcUrlEl.addEventListener('click', handleCopyRpc);
      rpcUrlEl._bound = true;
    }

    var form = document.getElementById('faucet-form');
    if (form && !form._bound) {
      form.addEventListener('submit', handleFaucetSubmit);
      form._bound = true;
    }

    bindInAppLinks(appEl);
  }

  function bindSearchHandler() {
    // Search box lives in the topbar (outside #app), so bind it once
    // on boot instead of per-view. Uses _bound flag to stay idempotent.
    var form = document.getElementById('search-form');
    if (form && !form._bound) {
      form.addEventListener('submit', handleSearchSubmit);
      form._bound = true;
    }
  }

  function handleSearchSubmit(ev) {
    ev.preventDefault();
    var input = document.getElementById('search-input');
    if (!input) return;
    var raw = (input.value || '').trim();
    if (!raw) return;

    // Strip any leading #, in case someone pastes "#1234".
    if (raw.charAt(0) === '#') raw = raw.slice(1);

    if (/^\\d+$/.test(raw)) {
      navigate('/block/' + raw);
      input.value = '';
      input.blur();
      return;
    }
    if (/^0x[0-9a-fA-F]{64}$/.test(raw)) {
      navigate('/tx/' + raw);
      input.value = '';
      input.blur();
      return;
    }
    if (/^0x[0-9a-fA-F]{40}$/.test(raw)) {
      navigate('/address/' + raw);
      input.value = '';
      input.blur();
      return;
    }
    if (/^ase1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38,58}$/i.test(raw)) {
      navigate('/address/' + raw.toLowerCase());
      input.value = '';
      input.blur();
      return;
    }
    toast('Enter a block number, tx hash (0x + 64 hex), or address (0x... or ase1...).', 'error');
  }

  function bindInAppLinks(root) {
    var links = root.querySelectorAll('a[data-route]');
    for (var i = 0; i < links.length; i++) {
      var a = links[i];
      if (a._bound) continue;
      a._bound = true;
      a.addEventListener('click', function (ev) {
        var href = this.getAttribute('data-route');
        if (!href) return;
        if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button !== 0) return;
        ev.preventDefault();
        navigate(href);
      });
    }
  }

  /* -----------------------------------------------------------------------
   * MetaMask + RPC + faucet handlers
   * ----------------------------------------------------------------------- */
  function handleAddMetamask() {
    if (!window.ethereum) {
      toast('MetaMask not detected. Install it first, then reload.', 'error');
      return;
    }
    if (!metadataCache) {
      toast('Metadata not loaded yet, try again in a moment.', 'info');
      return;
    }
    var md = metadataCache;
    var params = {
      chainId:           md.chainId,
      chainName:         md.chainName,
      nativeCurrency:    md.nativeCurrency,
      rpcUrls:           md.rpcUrls,
      blockExplorerUrls: md.blockExplorerUrls
    };
    window.ethereum.request({ method: 'wallet_addEthereumChain', params: [params] })
      .then(function () { toast('Asentum added to MetaMask.', 'success'); })
      .catch(function (err) {
        if (err && err.code === 4001) {
          toast('MetaMask add rejected.', 'info');
        } else {
          toast('MetaMask error: ' + (err && err.message || err), 'error');
        }
      });
  }

  function flashCopied() {
    var label = document.getElementById('rpc-label');
    if (!label) return;
    if (label._origText == null) label._origText = label.textContent;
    label.textContent = 'COPIED';
    if (label._copyTimer) clearTimeout(label._copyTimer);
    label._copyTimer = setTimeout(function () { label.textContent = label._origText; }, 1500);
  }

  function handleCopyRpc() {
    var url = (metadataCache && metadataCache.rpcUrls && metadataCache.rpcUrls[0]) || window.location.origin;
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(url).then(
        function () { flashCopied(); },
        function () { toast('Clipboard access denied.', 'error'); }
      );
    } else {
      // Fallback for non-secure contexts
      var ta = document.createElement('textarea');
      ta.value = url;
      document.body.appendChild(ta);
      ta.select();
      try { document.execCommand('copy'); flashCopied(); }
      catch (_) { toast('Could not copy to clipboard.', 'error'); }
      ta.remove();
    }
  }

  function handleFaucetSubmit(ev) {
    ev.preventDefault();
    var input = document.getElementById('faucet-address');
    var result = document.getElementById('faucet-result');
    if (!input || !result) return;

    var addr = (input.value || '').trim();
    var isHex = /^0x[0-9a-fA-F]{40}$/.test(addr);
    var isBech32 = /^ase1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38,58}$/i.test(addr);
    if (!isHex && !isBech32) {
      result.className = 'faucet-result error';
      result.textContent = 'Invalid address \u2014 must be 0x followed by 40 hex characters, or an ase1... bech32 address.';
      return;
    }

    result.className = 'faucet-result';
    result.textContent = 'Requesting drip\u2026';

    var btn = document.getElementById('btn-faucet');
    if (btn) btn.disabled = true;

    fetch('/dev/faucet', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ to: addr, amount: '100000000000000000000' })
    }).then(function (res) {
      return res.json().then(function (body) {
        if (res.status === 429) {
          var secs = body && body.retryAfterMs ? Math.ceil(body.retryAfterMs / 1000) : 60;
          result.className = 'faucet-result error';
          result.textContent = 'Faucet rate limited \u2014 try again in ' + secs + 's.';
          return;
        }
        if (!res.ok || !body || body.accepted !== true) {
          result.className = 'faucet-result error';
          result.textContent = 'Faucet error: ' + (body && body.reason ? body.reason : ('HTTP ' + res.status));
          return;
        }
        result.className = 'faucet-result success';
        result.innerHTML = '\u2713 100 ASE drip queued'
          + (body.txHash ? '<span class="tx-hash">tx ' + escapeHtml(shorten(body.txHash, 10, 8)) + '</span>' : '');
      });
    }).catch(function (err) {
      result.className = 'faucet-result error';
      result.textContent = 'Faucet error: ' + (err && err.message || err);
    }).then(function () {
      if (btn) btn.disabled = false;
    });
  }

  /* -----------------------------------------------------------------------
   * Governance views
   * ----------------------------------------------------------------------- */

  function govStatusBadge(status) {
    var colors = {
      active: { bg: 'var(--theme-blue-bg)', color: 'var(--theme-blue)' },
      passed: { bg: 'var(--theme-green-bg)', color: 'var(--theme-green)' },
      rejected: { bg: 'var(--theme-neutral-bg)', color: 'var(--theme-neutral)' },
      no_quorum: { bg: 'var(--theme-orange-bg)', color: 'var(--theme-orange)' },
    };
    var c = colors[status] || colors.active;
    return '<span style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.15em;padding:4px 10px;background:' + c.bg + ';color:' + c.color + '">' + escapeHtml(status.replace('_', ' ')) + '</span>';
  }

  function govCategoryBadge(cat) {
    var colors = {
      aip: { bg: 'var(--theme-blue-bg)', color: 'var(--theme-blue)' },
      parameter: { bg: 'var(--theme-orange-bg)', color: 'var(--theme-orange)' },
      library: { bg: 'var(--theme-green-bg)', color: 'var(--theme-green)' },
      general: { bg: 'var(--theme-neutral-bg)', color: 'var(--theme-neutral)' },
    };
    var c = colors[cat] || colors.general;
    return '<span style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.15em;padding:4px 10px;background:' + c.bg + ';color:' + c.color + '">' + escapeHtml(cat) + '</span>';
  }

  function govVoteBar(votesFor, votesAgainst) {
    var f = parseFloat(votesFor || '0');
    var a = parseFloat(votesAgainst || '0');
    var total = f + a;
    if (total === 0) return '<div style="height:6px;background:var(--bg-1);border-radius:3px;margin-top:8px"></div>';
    var pct = Math.round((f / total) * 100);
    return '<div style="height:6px;background:var(--bg-1);border-radius:3px;margin-top:8px;overflow:hidden;display:flex">'
      + '<div style="width:' + pct + '%;background:var(--theme-green);border-radius:3px 0 0 3px"></div>'
      + '<div style="width:' + (100 - pct) + '%;background:#F87171;border-radius:0 3px 3px 0"></div>'
      + '</div>';
  }

  function renderGovernanceList() {
    stopPolling();
    appEl.innerHTML = '<div style="text-align:center;padding:64px;color:var(--ink-2)">Loading proposals...</div>';
    fetchJson('/governance/proposals').then(function (data) {
      var proposals = data.proposals || [];
      var html = '';
      html += '<a href="/" class="detail-back" data-route="/">Explorer</a>';
      html += '<div class="detail-header">';
      html +=   '<h1 class="detail-title"><span class="label">On-Chain</span>Governance</h1>';
      html +=   '<div class="detail-nav">';
      html +=     '<a href="/governance/libraries" class="btn btn-secondary" data-route="/governance/libraries">Approved Libraries</a>';
      html +=   '</div>';
      html += '</div>';

      if (proposals.length === 0) {
        html += '<div style="text-align:center;padding:64px;color:var(--ink-2)">No proposals yet.</div>';
      } else {
        proposals.forEach(function (p) {
          var forAse = p.votesFor ? fmtAse(p.votesFor) : '0';
          var againstAse = p.votesAgainst ? fmtAse(p.votesAgainst) : '0';
          html += '<a href="/governance/' + p.id + '" data-route="/governance/' + p.id + '" class="gov-proposal-row">';
          html +=   '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:8px">';
          html +=     '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">';
          html +=       '<span style="font-family:var(--font-mono);font-size:11px;color:var(--ink-3)">#' + p.id + '</span>';
          html +=       govCategoryBadge(p.category || 'general');
          html +=       govStatusBadge(p.status || 'active');
          html +=     '</div>';
          html +=     '<span style="font-family:var(--font-mono);font-size:11px;color:var(--ink-3);flex-shrink:0">by ' + escapeHtml(shorten(p.proposer || '', 6, 4)) + '</span>';
          html +=   '</div>';
          html +=   '<div style="font-family:var(--font-plus);font-size:18px;font-weight:600;color:var(--ink-0);margin-bottom:6px">' + escapeHtml(p.title || 'Untitled') + '</div>';
          html +=   '<div style="font-family:var(--font-mono);font-size:12px;color:var(--ink-2);display:flex;gap:16px">';
          html +=     '<span>For: ' + escapeHtml(forAse) + ' ASE</span>';
          html +=     '<span>Against: ' + escapeHtml(againstAse) + ' ASE</span>';
          html +=   '</div>';
          html +=   govVoteBar(p.votesFor, p.votesAgainst);
          html += '</a>';
        });
      }

      appEl.innerHTML = html;
      bindInAppLinks(appEl);
    }).catch(function (err) {
      appEl.innerHTML = '<div style="text-align:center;padding:64px;color:var(--ink-2)">Failed to load proposals: ' + escapeHtml(String(err)) + '</div>';
    });
  }

  function renderGovernanceDetail(id) {
    stopPolling();
    appEl.innerHTML = '<div style="text-align:center;padding:64px;color:var(--ink-2)">Loading proposal...</div>';
    fetchJson('/governance/proposal/' + id).then(function (data) {
      var p = data.proposal;
      if (!p) {
        appEl.innerHTML = '<a href="/governance" class="detail-back" data-route="/governance">Governance</a><div style="text-align:center;padding:64px;color:var(--ink-2)">Proposal not found.</div>';
        bindInAppLinks(appEl);
        return;
      }
      var amendments = data.amendments || [];
      var forAse = p.votesFor ? fmtAse(p.votesFor) : '0';
      var againstAse = p.votesAgainst ? fmtAse(p.votesAgainst) : '0';

      var html = '';
      html += '<a href="/governance" class="detail-back" data-route="/governance">Governance</a>';
      html += '<div class="detail-header">';
      html +=   '<h1 class="detail-title"><span class="label">Proposal #' + p.id + '</span>' + escapeHtml(p.title || 'Untitled') + '</h1>';
      html += '</div>';

      html += '<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:24px;padding:0 16px">';
      html +=   govCategoryBadge(p.category || 'general');
      html +=   govStatusBadge(p.status || 'active');
      html += '</div>';

      html += '<div class="detail-grid">';
      html += row('Proposer', '<a href="/address/' + escapeHtml(p.proposer || '') + '" data-route="/address/' + escapeHtml(p.proposer || '') + '"><code>' + escapeHtml(p.proposer || '\u2014') + '</code></a>', p.proposer);
      html += row('Created at', 'Block #' + escapeHtml(String(p.createdAt || '\u2014')));
      html += row('Voting ends', 'Block #' + escapeHtml(String(p.votingEndsAt || '\u2014')));
      html += row('Votes for', escapeHtml(forAse) + ' ASE');
      html += row('Votes against', escapeHtml(againstAse) + ' ASE');
      html += row('Total weight', p.totalVoteWeight ? escapeHtml(fmtAse(p.totalVoteWeight)) + ' ASE' : '0 ASE');
      html += row('Bond', p.bond ? escapeHtml(fmtAse(p.bond)) + ' ASE' : '100 ASE');
      if (p.libraryName) html += row('Library', '<code>' + escapeHtml(p.libraryName) + '</code>');
      html += '</div>';

      html += govVoteBar(p.votesFor, p.votesAgainst);

      if (p.description) {
        html += '<div style="padding:24px 16px;font-size:14px;color:var(--ink-1);line-height:1.7;white-space:pre-wrap;word-break:break-word">' + escapeHtml(p.description) + '</div>';
      }

      if (p.librarySource) {
        html += '<div style="padding:0 16px 24px"><div style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:8px">Library Source</div>';
        html += '<pre style="background:var(--bg-0);border:1px solid var(--bg-1);padding:16px;font-family:var(--font-mono);font-size:12px;color:var(--ink-1);overflow-x:auto;max-height:400px;overflow-y:auto">' + escapeHtml(p.librarySource) + '</pre></div>';
      }

      if (amendments.length > 0) {
        html += '<div style="padding:24px 16px 12px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.15em">Amendments (' + amendments.length + ')</div>';
        amendments.forEach(function (a) {
          html += '<div style="padding:12px 16px;border-bottom:1px solid #1A1A1A">';
          html +=   '<div style="display:flex;gap:10px;align-items:center;margin-bottom:4px">';
          html +=     '<span style="font-family:var(--font-plus);font-size:14px;font-weight:600;color:var(--ink-0)">' + escapeHtml(a.title || 'Amendment #' + a.id) + '</span>';
          html +=     govStatusBadge(a.status || 'active');
          html +=   '</div>';
          if (a.description) html += '<div style="font-size:13px;color:var(--ink-2);line-height:1.6">' + escapeHtml(a.description.slice(0, 300)) + (a.description.length > 300 ? '...' : '') + '</div>';
          html += '</div>';
        });
      }

      html += '<div style="padding:32px 16px;text-align:center"><a href="https://asentum.com/governance/' + p.id + '" target="_blank" rel="noopener" style="font-family:var(--font-mono);font-size:12px;letter-spacing:0.15em;text-transform:uppercase;color:var(--theme-green);text-decoration:underline">Vote on asentum.com \u2192</a></div>';

      appEl.innerHTML = html;
      bindInAppLinks(appEl);
    }).catch(function (err) {
      appEl.innerHTML = '<a href="/governance" class="detail-back" data-route="/governance">Governance</a><div style="text-align:center;padding:64px;color:var(--ink-2)">Failed to load proposal: ' + escapeHtml(String(err)) + '</div>';
      bindInAppLinks(appEl);
    });
  }

  function renderLibrariesList() {
    stopPolling();
    appEl.innerHTML = '<div style="text-align:center;padding:64px;color:var(--ink-2)">Loading libraries...</div>';
    fetchJson('/governance/libraries').then(function (data) {
      var libs = data.libraries || [];
      var html = '';
      html += '<a href="/governance" class="detail-back" data-route="/governance">Governance</a>';
      html += '<div class="detail-header">';
      html +=   '<h1 class="detail-title"><span class="label">Governance</span>Approved Libraries</h1>';
      html += '</div>';

      if (libs.length === 0) {
        html += '<div style="text-align:center;padding:64px;color:var(--ink-2)">No approved libraries yet. Libraries are proposed and voted on through governance.</div>';
      } else {
        libs.forEach(function (lib) {
          html += '<div style="padding:20px 16px;border-bottom:1px solid #1A1A1A">';
          html +=   '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">';
          html +=     '<span style="font-family:var(--font-plus);font-size:18px;font-weight:600;color:var(--ink-0)">' + escapeHtml(lib.name || '?') + '</span>';
          html +=     govCategoryBadge('library');
          html +=   '</div>';
          if (lib.source) {
            html += '<details style="margin-top:8px"><summary style="font-family:var(--font-mono);font-size:11px;color:var(--ink-2);cursor:pointer;user-select:none">View source (' + lib.source.length + ' bytes)</summary>';
            html += '<pre style="background:var(--bg-0);border:1px solid var(--bg-1);padding:12px;font-family:var(--font-mono);font-size:12px;color:var(--ink-1);overflow-x:auto;margin-top:8px;max-height:300px;overflow-y:auto">' + escapeHtml(lib.source) + '</pre></details>';
          }
          html += '</div>';
        });
      }

      appEl.innerHTML = html;
      bindInAppLinks(appEl);
    }).catch(function (err) {
      appEl.innerHTML = '<a href="/governance" class="detail-back" data-route="/governance">Governance</a><div style="text-align:center;padding:64px;color:var(--ink-2)">Failed to load libraries: ' + escapeHtml(String(err)) + '</div>';
      bindInAppLinks(appEl);
    });
  }

  /* -----------------------------------------------------------------------
   * Router \u2014 History API, client-side
   * ----------------------------------------------------------------------- */
  function parseRoute() {
    var path = window.location.pathname || '/';
    if (path === '/' || path === '') return { name: 'overview' };
    var mb = path.match(/^\\/block\\/(\\d+)\\/?$/);
    if (mb) return { name: 'block', number: parseInt(mb[1], 10) };
    var mt = path.match(/^\\/tx\\/(0x[0-9a-fA-F]{64})\\/?$/);
    if (mt) return { name: 'tx', hash: mt[1] };
    var ma = path.match(/^\\/address\\/(0x[0-9a-fA-F]{40})\\/?$/);
    if (ma) return { name: 'address', addr: ma[1] };
    if (path === '/governance' || path === '/governance/') return { name: 'governance' };
    var mgp = path.match(/^\\/governance\\/(\\d+)\\/?$/);
    if (mgp) return { name: 'governance-detail', id: parseInt(mgp[1], 10) };
    if (path === '/governance/libraries' || path === '/governance/libraries/') return { name: 'libraries' };
    return { name: 'overview' };
  }

  function render() {
    var route = parseRoute();
    currentRoute = route;
    if (route.name === 'block') {
      renderBlockDetail(route.number);
      window.scrollTo(0, 0);
    } else if (route.name === 'tx') {
      renderTxDetail(route.hash);
      window.scrollTo(0, 0);
    } else if (route.name === 'address') {
      renderAddressDetail(route.addr);
      window.scrollTo(0, 0);
    } else if (route.name === 'governance') {
      renderGovernanceList();
      window.scrollTo(0, 0);
    } else if (route.name === 'governance-detail') {
      renderGovernanceDetail(route.id);
      window.scrollTo(0, 0);
    } else if (route.name === 'libraries') {
      renderLibrariesList();
      window.scrollTo(0, 0);
    } else {
      renderOverview();
    }
  }

  function navigate(path) {
    if (window.location.pathname === path) return;
    window.history.pushState({}, '', path);
    render();
  }

  window.addEventListener('popstate', render);

  /* Copy-to-clipboard handler for data-copy buttons */
  document.addEventListener('click', function (e) {
    var btn = e.target.closest && e.target.closest('[data-copy]');
    if (!btn) return;
    e.preventDefault();
    e.stopPropagation();
    var val = btn.getAttribute('data-copy');
    if (val && navigator.clipboard) {
      navigator.clipboard.writeText(val).then(function () {
        btn.classList.add('copied');
        setTimeout(function () { btn.classList.remove('copied'); }, 1200);
      });
    }
  });

  /* -----------------------------------------------------------------------
   * Boot
   * ----------------------------------------------------------------------- */
  captureOverviewSnapshot();
  bindSearchHandler();
  fetchMetadata().then(function () {
    render();
  });

})();
</script>

</body>
</html>`;

// src/explorer/playground.ts
var PLAYGROUND_HTML = [
  // We build the HTML as an array of strings joined at the end. This avoids
  // the template-literal-in-template-literal escaping problem that caused
  // the <span> tag leak in the first version.
  `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>Asentum \xB7 Contract Playground</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600;700&family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
  --bg-0: #000000; --bg-1: #0A0A0A; --bg-2: #0F0F0F; --bg-3: #1A1A1A;
  --line: #1F1F1F; --line-strong: #2A2A2A;
  --ink-0: #FFFFFF; --ink-1: #ACACAC; --ink-2: #7D7D7D; --ink-3: #5A5A5A;
  --accent: #ffffff; --accent-ok: #26CC6B; --accent-warn: #F29751; --accent-err: #F87171;
  --tag-transfer: #2DAEFF; --tag-call: #A78BFA; --tag-deploy: #F29751;
  --radius-sm: 0px;
  --font-sans: "Plus Jakarta Sans", "Albert Sans", -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: "DM Mono", "SF Mono", "Menlo", Consolas, monospace;
  --font-plus: "Plus Jakarta Sans", sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--bg-0); }
body { background: var(--bg-0); color: var(--ink-0); font-family: var(--font-sans); font-size: 14px; line-height: 1.55; -webkit-font-smoothing: antialiased; min-height: 100vh; font-variant-numeric: tabular-nums; }
a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; font-size: inherit; color: inherit; background: transparent; border: none; }
.topbar { margin: 0 4%; width: auto; padding: 16px 0; display: flex; align-items: center; justify-content: space-between; background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px); background-size: 100% 1px; background-repeat: no-repeat; background-position: bottom; }
.topbar-left { display: flex; align-items: center; gap: 16px; }
.logo { font-family: var(--font-plus); font-weight: 700; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-0); }
.badge { padding: 4px 10px; background: #081720; color: #2DAEFF; font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: 0.15em; text-transform: uppercase; border: none; }
.topbar-right { display: flex; align-items: center; gap: 14px; }
.wallet-status { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); letter-spacing: 0.1em; }
.wallet-status.connected { color: var(--accent-ok); }
main { margin: 0 4%; padding: 48px 0; }
.btn { padding: 10px 16px; font-family: var(--font-mono); font-size: 11px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; border: 1px solid var(--line-strong); color: var(--ink-1); transition: border-color 0.15s, color 0.15s, background 0.15s; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover:not(:disabled) { border-color: var(--ink-1); color: var(--ink-0); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #0A1D12; color: #26CC6B; border-color: #1A3A1A; }
.btn-primary:hover:not(:disabled) { border-color: #26CC6B; }
.btn-sm { padding: 6px 12px; font-size: 10px; }
h1 { font-family: var(--font-plus); font-size: 42px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 8px; line-height: 1.1; }
h1 .h1-dim { color: var(--ink-1); }
.subtitle { font-family: var(--font-mono); font-size: 14px; color: var(--ink-2); margin-bottom: 32px; max-width: 680px; line-height: 1.6; }
.template-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.template-bar .label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.15em; color: var(--ink-3); align-self: center; margin-right: 4px; }
.editor-area { border: 1px solid var(--line); background: var(--bg-1); margin-bottom: 16px; }
.editor-area textarea { display: block; width: 100%; min-height: 360px; font-family: var(--font-mono); font-size: 12px; line-height: 1.65; padding: 20px 24px; color: var(--ink-1); background: transparent; border: none; resize: vertical; outline: none; tab-size: 2; white-space: pre; overflow-x: auto; }
.editor-area textarea::placeholder { color: var(--ink-3); }
.deploy-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 32px; }
.result-card { background: var(--bg-1); border: 1px solid var(--line); padding: 16px 20px; margin-bottom: 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); line-height: 1.6; }
.result-card.ok { border-color: var(--accent-ok); }
.result-card.err { border-color: var(--accent-err); color: var(--accent-err); }
.result-card code { font-family: var(--font-mono); color: var(--ink-0); background: var(--bg-2); padding: 1px 6px; font-size: 11px; }
.result-card a { color: var(--ink-2); text-decoration: underline; }
.result-card a:hover { color: var(--ink-0); }
.fn-section { margin-top: 48px; padding-top: 32px; background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px); background-size: 100% 1px; background-repeat: no-repeat; background-position: top; }
.fn-section h2 { font-family: var(--font-plus); font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.fn-section .fn-sub { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); margin-bottom: 20px; }
.fn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
.fn-card { background: var(--bg-1); border: 1px solid var(--line); padding: 16px 18px; }
.fn-card h3 { font-family: var(--font-mono); font-size: 13px; color: var(--ink-0); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.fn-card h3 .fn-badge { font-family: var(--font-mono); font-size: 9px; padding: 3px 8px; text-transform: uppercase; letter-spacing: 0.15em; font-weight: 500; }
.fn-badge-view { background: #081720; color: #2DAEFF; }
.fn-badge-write { background: #150D20; color: #A78BFA; }
.fn-card .fn-args input { width: 100%; padding: 8px 10px; background: #050505; border: 1px solid var(--line); color: var(--ink-0); font-family: var(--font-mono); font-size: 11px; margin-bottom: 6px; }
.fn-card .fn-args input:focus { outline: none; border-color: var(--ink-3); }
.fn-card .fn-args input::placeholder { color: var(--ink-3); }
.fn-card .fn-result { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); padding: 8px 10px; background: #050505; margin-top: 8px; min-height: 20px; word-break: break-all; white-space: pre-wrap; }
.fn-card .fn-result.ok { color: var(--accent-ok); }
.fn-card .fn-result.err { color: var(--accent-err); }
.footer { margin: 64px 4% 0; padding: 32px 0 48px; background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px); background-size: 100% 1px; background-repeat: no-repeat; background-position: top; font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.1em; }
/* \u2500\u2500\u2500 Wallet connect modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85); backdrop-filter: blur(4px); display: none; align-items: center; justify-content: center; z-index: 1000; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { background: var(--bg-1); border: 1px solid var(--line); width: 100%; max-width: 640px; max-height: 90vh; overflow-y: auto; }
.modal-header { padding: 20px 24px; display: flex; align-items: center; justify-content: space-between; background-image: repeating-linear-gradient(to right, #363636 0, #363636 16px, transparent 16px, transparent 28px); background-size: 100% 1px; background-repeat: no-repeat; background-position: bottom; }
.modal-header h2 { font-family: var(--font-plus); font-size: 18px; font-weight: 700; }
.modal-close { color: var(--ink-3); padding: 4px 8px; font-size: 18px; line-height: 1; }
.modal-close:hover { color: var(--ink-0); }
.modal-body { padding: 28px 24px; }
.connect-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 540px) { .connect-cards { grid-template-columns: 1fr; } }
.connect-card { background: var(--bg-2); border: 1px solid var(--line); padding: 28px 20px; text-align: center; transition: border-color 0.15s, background 0.15s; cursor: pointer; }
.connect-card:hover { border-color: var(--ink-3); background: var(--bg-3); }
.connect-card-icon { width: 44px; height: 44px; margin: 0 auto 14px; display: flex; align-items: center; justify-content: center; font-size: 22px; background: var(--bg-3); border: 1px solid var(--line); }
.connect-card h3 { font-family: var(--font-plus); font-size: 14px; font-weight: 700; color: var(--ink-0); margin-bottom: 6px; }
.connect-card p { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); line-height: 1.55; margin-bottom: 16px; min-height: 50px; }
.connect-card .btn { width: 100%; justify-content: center; }
.connect-state { text-align: center; padding: 16px 0; }
.connect-state h3 { font-family: var(--font-plus); font-size: 18px; font-weight: 700; color: var(--ink-0); margin-bottom: 8px; }
.connect-state p { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); margin-bottom: 16px; line-height: 1.6; }
.connect-code { font-family: var(--font-mono); font-size: 36px; letter-spacing: 0.25em; color: var(--ink-0); padding: 18px 24px; background: var(--bg-3); border: 1px solid var(--line); display: inline-block; margin: 8px 0 16px; font-weight: 700; }
.connect-code-hint { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); margin-top: 8px; }
.connect-code-hint strong { color: var(--ink-1); }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--line-strong); border-top-color: var(--accent-ok); border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 8px; }
@keyframes spin { to { transform: rotate(360deg); } }
.connect-success-icon { width: 56px; height: 56px; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center; background: #0A1D12; border: 1px solid #1A3A1A; }
.connect-error { color: var(--accent-err); font-family: var(--font-mono); font-size: 11px; margin-top: 12px; }
.connect-back { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); text-decoration: underline; margin-top: 12px; display: inline-block; cursor: pointer; }
.connect-back:hover { color: var(--ink-1); }
.footer a { color: var(--ink-2); }
.footer a:hover { color: var(--ink-0); }
h1 .h1-dim { color: var(--ink-1); }
@media (max-width: 720px) {
  main { padding: 32px 0; }
  .topbar { padding: 14px 0; }
  h1 { font-size: 28px; }
  .fn-grid { grid-template-columns: 1fr; }
  .editor-area textarea { min-height: 260px; font-size: 11px; }
}
</style>
</head>
<body>

<header class="topbar">
  <div class="topbar-left">
    <a href="/" style="display:flex;align-items:center;gap:12px">
      <span class="logo">ASENTUM</span>
      <span class="badge">PLAYGROUND</span>
    </a>
  </div>
  <div class="topbar-right">
    <span class="wallet-status" id="wallet-status">not connected</span>
    <button class="btn btn-sm" id="connect-btn" type="button">Connect wallet</button>
  </div>
</header>

<main>
  <h1><span class="h1-dim">Contract </span>playground.</h1>
  <p class="subtitle">
    Write a JavaScript smart contract, deploy it to the live testnet,
    and interact with its functions \u2014 all from this page.
  </p>

  <div class="template-bar">
    <span class="label">Templates</span>
    <button class="btn btn-sm" data-template="token" type="button">Token</button>
    <button class="btn btn-sm" data-template="guestbook" type="button">Guestbook</button>
    <button class="btn btn-sm" data-template="tipjar" type="button">Tip Jar</button>
    <button class="btn btn-sm" data-template="blank" type="button">Blank</button>
  </div>

  <div class="editor-area">
    <textarea id="editor-input" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" placeholder="// Write your contract here, or pick a template above."></textarea>
  </div>

  <div class="deploy-bar">
    <button class="btn btn-primary" id="deploy-btn" type="button" disabled>Deploy contract</button>
    <span id="deploy-status" style="font-size:11px;color:var(--ink-2)">connect wallet first</span>
  </div>

  <div id="deploy-result"></div>
  <div id="fn-section"></div>
</main>

<!-- \u2500\u2500\u2500 Wallet connect modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->
<div class="modal-backdrop" id="connect-modal">
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="connect-modal-title">
    <div class="modal-header">
      <h2 id="connect-modal-title">Connect a wallet</h2>
      <button class="modal-close" id="modal-close-btn" aria-label="Close">&times;</button>
    </div>
    <div class="modal-body" id="connect-modal-body">
      <!-- View 1: pick a method -->
      <div id="connect-pick">
        <div class="connect-cards">
          <div class="connect-card" id="card-extension" role="button" tabindex="0">
            <div class="connect-card-icon">&#129520;</div>
            <h3>Connect with Extension</h3>
            <p>Use the Asentum browser extension. Best on desktop.</p>
            <button class="btn btn-primary">Use extension</button>
          </div>
          <div class="connect-card" id="card-code" role="button" tabindex="0">
            <div class="connect-card-icon">&#128241;</div>
            <h3>Connect with Code</h3>
            <p>Get a 6-character code, type it into your Asentum bot. Works on any device.</p>
            <button class="btn btn-primary">Get a code</button>
          </div>
        </div>
      </div>

      <!-- View 2: extension flow (waiting for user to approve in extension) -->
      <div id="connect-extension" style="display:none">
        <div class="connect-state">
          <div style="margin-bottom:12px"><span class="spinner"></span></div>
          <h3>Waiting for the extension</h3>
          <p>Approve the connection in your Asentum extension popup.</p>
          <div class="connect-error" id="extension-error" style="display:none"></div>
          <a class="connect-back" id="extension-back">&larr; Back</a>
        </div>
      </div>

      <!-- View 3: code flow (showing the code, polling) -->
      <div id="connect-code" style="display:none">
        <div class="connect-state">
          <p>Open <strong>@AsentumBot</strong> on Telegram, tap <strong>Connect dapp</strong>, and reply with this code:</p>
          <div class="connect-code" id="connect-code-display">------</div>
          <div class="connect-code-hint">
            <span class="spinner"></span> waiting for approval&hellip;
          </div>
          <div class="connect-code-hint" style="margin-top:6px">
            Code expires in <strong id="connect-code-ttl">5:00</strong>
          </div>
          <div class="connect-error" id="code-error" style="display:none"></div>
          <a class="connect-back" id="code-back">&larr; Back</a>
        </div>
      </div>

      <!-- View 4: success -->
      <div id="connect-success" style="display:none">
        <div class="connect-state">
          <div class="connect-success-icon">
            <svg viewBox="0 0 24 24" fill="none" stroke="#26CC6B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:28px;height:28px"><path d="M20 6L9 17l-5-5"/></svg>
          </div>
          <h3>Wallet connected</h3>
          <p style="color:var(--ink-1)">Connected as</p>
          <div class="connect-code" id="connect-success-address" style="font-size:13px;letter-spacing:0.05em">0x&hellip;</div>
          <div style="margin-top:16px">
            <button class="btn btn-primary" id="connect-success-close">Close</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

<!-- Contract templates stored as non-executing <script> blocks.
     The JS reads them via .textContent \u2014 no escaping, no corruption. -->`,
  `<script type="text/plain" id="tpl-token">`,
  // Token template
  `// Simple token contract \u2014 deploy, then call init() to mint.

const TOTAL_SUPPLY = '1000000';

function init() {
  storage.set('name', 'My Token');
  storage.set('symbol', 'MTK');
  storage.set('totalSupply', TOTAL_SUPPLY);
  storage.set('balance:' + msg.sender, TOTAL_SUPPLY);
  emit('Mint', { to: msg.sender, amount: TOTAL_SUPPLY });
}

function transfer(to, amount) {
  const amt = BigInt(amount);
  const senderBal = BigInt(storage.get('balance:' + msg.sender) || '0');
  assert(senderBal >= amt, 'insufficient balance');
  const receiverBal = BigInt(storage.get('balance:' + to) || '0');
  storage.set('balance:' + msg.sender, (senderBal - amt).toString());
  storage.set('balance:' + to, (receiverBal + amt).toString());
  emit('Transfer', { from: msg.sender, to: to, amount: amount });
}

function balanceOf(addr) {
  return storage.get('balance:' + addr) || '0';
}

function name() { return storage.get('name'); }
function symbol() { return storage.get('symbol'); }
function totalSupply() { return storage.get('totalSupply'); }`,
  `</script>
<script type="text/plain" id="tpl-guestbook">`,
  `// Guestbook \u2014 anyone can sign it, anyone can read it.

function init() {
  storage.set('count', '0');
  emit('GuestbookCreated', { owner: msg.sender });
}

function sign(message) {
  assert(message && message.length > 0, 'message must not be empty');
  assert(message.length <= 280, 'message too long (max 280 chars)');
  const count = parseInt(storage.get('count') || '0');
  const entry = JSON.stringify({
    author: msg.sender,
    message: message,
    index: count,
  });
  storage.set('entry:' + count, entry);
  storage.set('count', (count + 1).toString());
  emit('Signed', { author: msg.sender, index: count, message: message });
}

function getEntry(index) {
  const raw = storage.get('entry:' + index);
  return raw ? JSON.parse(raw) : null;
}

function getCount() { return storage.get('count') || '0'; }`,
  `</script>
<script type="text/plain" id="tpl-tipjar">`,
  `// Tip jar \u2014 accept ASE tips and track totals per tipper.

function init() {
  storage.set('owner', msg.sender);
  storage.set('totalTips', '0');
  emit('TipJarCreated', { owner: msg.sender });
}

function tip() {
  const amount = BigInt(msg.value);
  assert(amount > 0n, 'tip must be > 0');
  const prev = BigInt(storage.get('tips:' + msg.sender) || '0');
  storage.set('tips:' + msg.sender, (prev + amount).toString());
  const total = BigInt(storage.get('totalTips') || '0');
  storage.set('totalTips', (total + amount).toString());
  emit('Tip', { from: msg.sender, amount: msg.value });
}

function getTips(addr) { return storage.get('tips:' + addr) || '0'; }
function getTotalTips() { return storage.get('totalTips') || '0'; }
function getOwner() { return storage.get('owner'); }`,
  `</script>
<script type="text/plain" id="tpl-blank">// Your contract here.

function init() {
  storage.set('greeting', 'hello world');
  emit('Init', { sender: msg.sender });
}

function getGreeting() {
  return storage.get('greeting');
}

function setGreeting(value) {
  storage.set('greeting', value);
  emit('GreetingChanged', { sender: msg.sender, value: value });
}</script>

<footer class="footer">
  <a href="/">\u2190 Back to explorer</a>
  &nbsp;\xB7&nbsp;
  Asentum \xB7 post-quantum L1 \xB7 the everybody chain
</footer>

<script>
(function () {
  'use strict';

  var walletAddress = null;
  var connectionMethod = null; // 'extension' or 'bot'
  var botSessionId = null;     // populated when connectionMethod === 'bot'
  var contractAddress = null;
  var parsedFunctions = [];

  // Read templates from the <script type="text/plain"> blocks.
  var TEMPLATES = {
    token: (document.getElementById('tpl-token') || {}).textContent || '',
    guestbook: (document.getElementById('tpl-guestbook') || {}).textContent || '',
    tipjar: (document.getElementById('tpl-tipjar') || {}).textContent || '',
    blank: (document.getElementById('tpl-blank') || {}).textContent || ''
  };

  var editorInput = document.getElementById('editor-input');
  var connectBtn = document.getElementById('connect-btn');
  var deployBtn = document.getElementById('deploy-btn');
  var deployStatus = document.getElementById('deploy-status');
  var deployResult = document.getElementById('deploy-result');
  var fnSection = document.getElementById('fn-section');
  var walletStatusEl = document.getElementById('wallet-status');

  // --- templates ---
  document.querySelectorAll('[data-template]').forEach(function (btn) {
    btn.addEventListener('click', function () {
      var key = btn.getAttribute('data-template');
      editorInput.value = (TEMPLATES[key] || '').trim();
      contractAddress = null;
      fnSection.innerHTML = '';
      deployResult.innerHTML = '';
    });
  });
  // Load token template by default.
  editorInput.value = (TEMPLATES.token || '').trim();

  // --- wallet ---
  function waitForProvider(timeout) {
    return new Promise(function (resolve, reject) {
      if (window.asentum) return resolve(window.asentum);
      var start = Date.now();
      var iv = setInterval(function () {
        if (window.asentum) { clearInterval(iv); resolve(window.asentum); }
        else if (Date.now() - start > (timeout || 3000)) {
          clearInterval(iv);
          reject(new Error('Asentum extension not detected. Install it from /install/asentum-extension.zip'));
        }
      }, 50);
    });
  }

  function updateWalletUI() {
    if (walletAddress) {
      walletStatusEl.textContent = walletAddress.slice(0, 8) + '\\u2026' + walletAddress.slice(-6);
      walletStatusEl.className = 'wallet-status connected';
      connectBtn.textContent = 'Disconnect';
      deployBtn.disabled = false;
      deployStatus.textContent = '';
    } else {
      walletStatusEl.textContent = 'not connected';
      walletStatusEl.className = 'wallet-status';
      connectBtn.textContent = 'Connect wallet';
      deployBtn.disabled = true;
      deployStatus.textContent = 'connect wallet first';
    }
  }

  // \u2500\u2500\u2500 Wallet connect modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
  var WALLET_BOT_API = 'https://wallet.asentum.com';

  var modal = document.getElementById('connect-modal');
  var modalCloseBtn = document.getElementById('modal-close-btn');
  var pickView = document.getElementById('connect-pick');
  var extensionView = document.getElementById('connect-extension');
  var codeView = document.getElementById('connect-code');
  var successView = document.getElementById('connect-success');
  var cardExtension = document.getElementById('card-extension');
  var cardCode = document.getElementById('card-code');
  var extensionBackBtn = document.getElementById('extension-back');
  var codeBackBtn = document.getElementById('code-back');
  var extensionErrorEl = document.getElementById('extension-error');
  var codeErrorEl = document.getElementById('code-error');
  var connectCodeDisplay = document.getElementById('connect-code-display');
  var connectCodeTtl = document.getElementById('connect-code-ttl');
  var connectSuccessAddress = document.getElementById('connect-success-address');
  var connectSuccessClose = document.getElementById('connect-success-close');

  var pollHandle = null;
  var ttlHandle = null;

  function showView(name) {
    pickView.style.display      = name === 'pick'      ? '' : 'none';
    extensionView.style.display = name === 'extension' ? '' : 'none';
    codeView.style.display      = name === 'code'      ? '' : 'none';
    successView.style.display   = name === 'success'   ? '' : 'none';
  }
  function openModal() {
    extensionErrorEl.style.display = 'none';
    codeErrorEl.style.display = 'none';
    showView('pick');
    modal.classList.add('open');
  }
  function closeModal() {
    if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
    if (ttlHandle) { clearInterval(ttlHandle); ttlHandle = null; }
    modal.classList.remove('open');
  }
  modalCloseBtn.addEventListener('click', closeModal);
  modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
  document.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
  });

  // Card 1: extension
  function startExtensionFlow() {
    extensionErrorEl.style.display = 'none';
    showView('extension');
    waitForProvider(3000).then(function (provider) {
      return provider.connect();
    }).then(function (res) {
      walletAddress = res.address;
      connectionMethod = 'extension';
      botSessionId = null;
      connectSuccessAddress.textContent = res.address;
      updateWalletUI();
      showView('success');
    }).catch(function (err) {
      extensionErrorEl.textContent = err.message || String(err);
      extensionErrorEl.style.display = '';
    });
  }
  cardExtension.addEventListener('click', startExtensionFlow);
  cardExtension.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startExtensionFlow(); }
  });
  extensionBackBtn.addEventListener('click', function () { showView('pick'); });

  // Card 2: code
  function tryCreateSession(apiBase) {
    return fetch(apiBase + '/api/sessions/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        dappOrigin: location.origin,
        dappName: 'Asentum Playground',
      }),
    }).then(function (res) {
      if (!res.ok) throw new Error('HTTP ' + res.status);
      return res.json();
    });
  }
  function startCodeFlow() {
    codeErrorEl.style.display = 'none';
    showView('code');
    connectCodeDisplay.textContent = '------';

    tryCreateSession(WALLET_BOT_API).then(function (data) {
      connectCodeDisplay.textContent = data.code;
      var expires = new Date(data.expiresAt).getTime();
      // Live countdown
      function tick() {
        var ms = expires - Date.now();
        if (ms <= 0) {
          connectCodeTtl.textContent = 'expired';
          if (ttlHandle) { clearInterval(ttlHandle); ttlHandle = null; }
          if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
          codeErrorEl.textContent = 'Code expired. Click Back and try again.';
          codeErrorEl.style.display = '';
          return;
        }
        var s = Math.floor(ms / 1000);
        var mm = Math.floor(s / 60);
        var ss = s % 60;
        connectCodeTtl.textContent = mm + ':' + (ss < 10 ? '0' : '') + ss;
      }
      tick();
      ttlHandle = setInterval(tick, 1000);
      // Poll for status
      function poll() {
        fetch(WALLET_BOT_API + '/api/sessions/' + data.sessionId).then(function (r) {
          if (!r.ok) throw new Error('HTTP ' + r.status);
          return r.json();
        }).then(function (s) {
          if (s.status === 'connected') {
            walletAddress = s.address;
            connectionMethod = 'bot';
            botSessionId = data.sessionId;
            connectSuccessAddress.textContent = s.address;
            updateWalletUI();
            if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
            if (ttlHandle) { clearInterval(ttlHandle); ttlHandle = null; }
            showView('success');
          } else if (s.status === 'rejected') {
            codeErrorEl.textContent = 'Connection rejected from the bot.';
            codeErrorEl.style.display = '';
            if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
            if (ttlHandle) { clearInterval(ttlHandle); ttlHandle = null; }
          } else if (s.status === 'expired') {
            codeErrorEl.textContent = 'Session expired.';
            codeErrorEl.style.display = '';
            if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
            if (ttlHandle) { clearInterval(ttlHandle); ttlHandle = null; }
          }
        }).catch(function () {
          // Transient \u2014 keep polling
        });
      }
      pollHandle = setInterval(poll, 2000);
      poll();
    }).catch(function (err) {
      codeErrorEl.textContent = 'Could not reach wallet bot: ' + (err.message || err);
      codeErrorEl.style.display = '';
    });
  }
  cardCode.addEventListener('click', startCodeFlow);
  cardCode.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startCodeFlow(); }
  });
  codeBackBtn.addEventListener('click', function () {
    if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
    if (ttlHandle) { clearInterval(ttlHandle); ttlHandle = null; }
    showView('pick');
  });

  connectSuccessClose.addEventListener('click', closeModal);

  // Hijack the original connect button
  connectBtn.addEventListener('click', function () {
    if (walletAddress) {
      // Disconnect: try the extension's disconnect (no-op for code-based)
      if (window.asentum && window.asentum.disconnect) {
        window.asentum.disconnect().catch(function (err) { console.warn(err); });
      }
      walletAddress = null;
      connectionMethod = null;
      botSessionId = null;
      updateWalletUI();
      return;
    }
    openModal();
  });

  // --- bot sign-request helper ---
  // Posts a sign request to wallet.asentum.com, polls until status changes,
  // returns { txHash, contractAddress? }. Throws on rejection / expiry.
  async function botSignRequest(payload, statusUpdateFn) {
    if (!botSessionId) throw new Error('no active bot session \u2014 reconnect via the wallet modal');

    var createRes;
    try {
      var r = await fetch(WALLET_BOT_API + '/api/sessions/' + botSessionId + '/sign-request', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!r.ok) {
        var bodyText = await r.text();
        throw new Error('HTTP ' + r.status + ' from wallet bot: ' + bodyText);
      }
      createRes = await r.json();
    } catch (err) {
      throw new Error('Could not reach wallet bot (' + WALLET_BOT_API + '): ' + (err && err.message ? err.message : err));
    }

    if (statusUpdateFn) statusUpdateFn('Waiting for approval in @AsentumBot\u2026');

    // Poll
    return new Promise(function (resolve, reject) {
      var iv = setInterval(async function () {
        try {
          var r = await fetch(WALLET_BOT_API + '/api/sessions/' + botSessionId + '/sign-requests/' + createRes.requestId);
          if (!r.ok) throw new Error('poll HTTP ' + r.status);
          var s = await r.json();
          if (s.status === 'approved') {
            clearInterval(iv);
            resolve({ txHash: s.txHash, contractAddress: s.contractAddress });
          } else if (s.status === 'rejected') {
            clearInterval(iv);
            reject(new Error(s.error || 'request rejected in bot'));
          } else if (s.status === 'expired') {
            clearInterval(iv);
            reject(new Error('request expired (5 min). Approve faster next time.'));
          }
        } catch (err) {
          // keep polling on transient errors
        }
      }, 2000);
    });
  }

  // --- deploy ---
  deployBtn.addEventListener('click', async function () {
    if (!walletAddress) return;
    var source = editorInput.value.trim();
    if (!source) {
      deployResult.innerHTML = '<div class="result-card err">Contract source is empty.</div>';
      return;
    }
    deployBtn.disabled = true;
    deployBtn.textContent = 'Deploying\\u2026';
    deployResult.innerHTML = '<div class="result-card">Waiting for wallet approval\\u2026</div>';
    try {
      var res;
      if (connectionMethod === 'bot') {
        res = await botSignRequest(
          { type: 'contract_deploy', source: source },
          function (msg) {
            deployResult.innerHTML = '<div class="result-card"><span class="spinner"></span> ' + esc(msg) + '</div>';
          }
        );
      } else {
        res = await window.asentum.deployContract({ source: source });
      }
      contractAddress = res.contractAddress;
      deployResult.innerHTML = '<div class="result-card ok">'
        + '<strong>Contract deployed!</strong><br>'
        + 'Address: <code>' + esc(res.contractAddress) + '</code><br>'
        + 'Tx: <code>' + esc(res.txHash.slice(0, 18)) + '\\u2026</code> '
        + '<a href="/tx/' + esc(res.txHash) + '" target="_blank">view on explorer \\u2192</a><br>'
        + '<br>Now call <code>init()</code> below to initialize the contract.'
        + '</div>';
      renderFunctions(source, res.contractAddress);
    } catch (err) {
      deployResult.innerHTML = '<div class="result-card err">' + esc(err.message) + '</div>';
    }
    deployBtn.disabled = false;
    deployBtn.textContent = 'Deploy contract';
  });

  // --- parse + render functions ---
  function parseFns(source) {
    var re = /function\\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*\\(([^)]*)\\)/g;
    var fns = [];
    var m;
    while ((m = re.exec(source)) !== null) {
      var name = m[1];
      var params = m[2].split(',').map(function (p) { return p.trim(); }).filter(Boolean);
      var bodyStart = source.indexOf('{', m.index + m[0].length);
      var depth = 1, bodyEnd = bodyStart + 1;
      while (depth > 0 && bodyEnd < source.length) {
        if (source[bodyEnd] === '{') depth++;
        if (source[bodyEnd] === '}') depth--;
        bodyEnd++;
      }
      var body = source.slice(bodyStart, bodyEnd);
      var isWrite = /storage\\.set|emit\\(/.test(body);
      fns.push({ name: name, params: params, isWrite: isWrite });
    }
    return fns;
  }

  function renderFunctions(source, addr) {
    var fns = parseFns(source);
    if (fns.length === 0) {
      fnSection.innerHTML = '<div class="fn-section"><h2>Functions</h2><p class="fn-sub">No functions found.</p></div>';
      return;
    }
    var html = '<div class="fn-section">';
    html += '<h2>Contract functions</h2>';
    html += '<p class="fn-sub">Interact with <code>' + esc(addr.slice(0, 10)) + '\\u2026' + esc(addr.slice(-6)) + '</code>. View functions are free; write functions send a transaction.</p>';
    html += '<div class="fn-grid">';
    fns.forEach(function (fn, i) {
      html += '<div class="fn-card" data-fn-index="' + i + '">';
      html += '<h3><span>' + esc(fn.name) + '()</span><span class="fn-badge ' + (fn.isWrite ? 'fn-badge-write' : 'fn-badge-view') + '">' + (fn.isWrite ? 'write' : 'view') + '</span></h3>';
      if (fn.params.length > 0) {
        html += '<div class="fn-args">';
        fn.params.forEach(function (p) {
          html += '<input type="text" placeholder="' + esc(p) + '" data-param="' + esc(p) + '" autocomplete="off">';
        });
        html += '</div>';
      }
      html += '<button class="btn btn-sm ' + (fn.isWrite ? '' : 'btn-primary') + '" data-call-fn="' + i + '" type="button">' + (fn.isWrite ? 'Send tx' : 'Call') + '</button>';
      html += '<div class="fn-result" id="fn-result-' + i + '"></div>';
      html += '</div>';
    });
    html += '</div></div>';
    fnSection.innerHTML = html;
    parsedFunctions = fns;
    document.querySelectorAll('[data-call-fn]').forEach(function (btn) {
      btn.addEventListener('click', function () {
        handleFnCall(parseInt(btn.getAttribute('data-call-fn')), addr);
      });
    });
  }

  async function handleFnCall(fnIndex, addr) {
    var fn = parsedFunctions[fnIndex];
    if (!fn) return;
    var resultEl = document.getElementById('fn-result-' + fnIndex);
    var card = document.querySelector('[data-fn-index="' + fnIndex + '"]');
    var inputs = card.querySelectorAll('input[data-param]');
    var args = [];
    inputs.forEach(function (input) { args.push(input.value); });
    resultEl.className = 'fn-result';
    resultEl.textContent = 'calling\\u2026';
    try {
      if (fn.isWrite) {
        if (!walletAddress) throw new Error('connect wallet first');
        var res;
        if (connectionMethod === 'bot') {
          resultEl.className = 'fn-result';
          resultEl.textContent = 'waiting for approval in @AsentumBot\u2026';
          res = await botSignRequest({
            type: 'contract_call',
            to: addr,
            method: fn.name,
            args: args,
          });
        } else {
          res = await window.asentum.callContract({ to: addr, method: fn.name, args: args });
        }
        resultEl.className = 'fn-result ok';
        resultEl.innerHTML = '\\u2713 tx ' + esc(res.txHash.slice(0, 18)) + '\\u2026 <a href="/tx/' + esc(res.txHash) + '" target="_blank" style="color:var(--ink-1);text-decoration:underline;font-size:10px">view</a>';
      } else {
        // View calls don't need signatures \u2014 they go via the public RPC.
        // For bot-connected wallets we still call the same view endpoint
        // through window.asentum (extension) when present; otherwise hit
        // the chain directly.
        var vr;
        if (window.asentum && window.asentum.viewContract) {
          vr = await window.asentum.viewContract({ to: addr, method: fn.name, args: args });
        } else {
          var viewRes = await fetch('/view', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ to: addr, method: fn.name, args: args }),
          });
          vr = await viewRes.json();
        }
        resultEl.className = 'fn-result ok';
        var d = vr.result;
        if (typeof d === 'object') d = JSON.stringify(d, null, 2);
        resultEl.textContent = d != null ? String(d) : '(no return value)';
      }
    } catch (err) {
      resultEl.className = 'fn-result err';
      resultEl.textContent = err.message;
    }
  }

  function esc(s) {
    if (s == null) return '';
    var p = document.createElement('p');
    p.textContent = String(s);
    return p.innerHTML;
  }

  // Auto-detect wallet on load.
  waitForProvider(2000).then(function (p) {
    return p.isConnected().then(function (c) {
      if (!c) return;
      return p.getAddress().then(function (a) { walletAddress = a; updateWalletUI(); });
    });
  }).catch(function () {});

})();
</script>
</body>
</html>`
].join("\n");

// src/rpc/log-filter.ts
function parseLogFilter(raw, currentHeight) {
  const filter = raw === void 0 || raw === null ? {} : raw;
  if (typeof filter !== "object" || Array.isArray(filter)) {
    throw new Error("log filter must be an object");
  }
  const f = filter;
  const fromResolved = resolveBlockParam(f.fromBlock, "latest", currentHeight);
  const toResolved = resolveBlockParam(f.toBlock, "latest", currentHeight);
  const fromBlock = fromResolved.number ?? currentHeight;
  const toBlock = toResolved.number ?? currentHeight;
  if (toBlock < fromBlock) {
    throw new Error(
      `log filter: toBlock ${toBlock} < fromBlock ${fromBlock}`
    );
  }
  const addresses = parseAddressFilter(f.address);
  const topics = parseTopicFilter(f.topics);
  return { fromBlock, toBlock, addresses, topics };
}
function matchesAddress(log, addresses) {
  if (addresses === null) return true;
  return addresses.has(log.address.toLowerCase());
}
function matchesTopics(log, topics) {
  if (topics.length === 0) return true;
  if (log.topics.length < topics.length) return false;
  for (let i = 0; i < topics.length; i++) {
    const allowed = topics[i];
    if (allowed === null || allowed === void 0) continue;
    const t = log.topics[i];
    if (t === void 0 || !allowed.has(t.toLowerCase())) return false;
  }
  return true;
}
function matchesFilter(log, filter) {
  return matchesAddress(log, filter.addresses) && matchesTopics(log, filter.topics);
}
function resolveBlockParam(raw, fallback, currentHeight) {
  if (raw === void 0 || raw === null) {
    return parseBlockParam(fallback, currentHeight);
  }
  return parseBlockParam(raw, currentHeight);
}
function parseAddressFilter(raw) {
  if (raw === void 0 || raw === null) return null;
  if (typeof raw === "string") {
    return /* @__PURE__ */ new Set([raw.toLowerCase()]);
  }
  if (Array.isArray(raw)) {
    const out = /* @__PURE__ */ new Set();
    for (const a of raw) {
      if (typeof a !== "string") {
        throw new Error(`log filter: address list must contain strings, got ${typeof a}`);
      }
      out.add(a.toLowerCase());
    }
    return out;
  }
  throw new Error(`log filter: address must be a string or array of strings`);
}
function parseTopicFilter(raw) {
  if (raw === void 0 || raw === null) return [];
  if (!Array.isArray(raw)) {
    throw new Error("log filter: topics must be an array");
  }
  const out = [];
  for (const position of raw) {
    if (position === null || position === void 0) {
      out.push(null);
      continue;
    }
    if (typeof position === "string") {
      out.push(/* @__PURE__ */ new Set([position.toLowerCase()]));
      continue;
    }
    if (Array.isArray(position)) {
      const set = /* @__PURE__ */ new Set();
      for (const t of position) {
        if (t === null) continue;
        if (typeof t !== "string") {
          throw new Error(
            `log filter: topic positions must be strings or arrays of strings`
          );
        }
        set.add(t.toLowerCase());
      }
      out.push(set.size === 0 ? null : set);
      continue;
    }
    throw new Error(`log filter: topic position must be null, string, or array`);
  }
  return out;
}

// src/rpc/filter-store.ts
var DEFAULT_FILTER_TTL_MS = 5 * 60 * 1e3;
var MAX_FILTERS = 1024;
var FilterStore = class {
  filters = /* @__PURE__ */ new Map();
  nextIdCounter = 1n;
  ttlMs;
  maxFilters;
  now;
  constructor(opts = {}) {
    this.ttlMs = opts.ttlMs ?? DEFAULT_FILTER_TTL_MS;
    this.maxFilters = opts.maxFilters ?? MAX_FILTERS;
    this.now = opts.now ?? Date.now;
  }
  /** Create a new log filter. Returns the filter ID (0x-prefixed hex). */
  createLogFilter(filter, currentHeight) {
    this.evictStale();
    if (this.filters.size >= this.maxFilters) {
      throw new Error(`filter store full (max ${this.maxFilters})`);
    }
    const id = this.allocateId();
    const entry = {
      id,
      kind: "log",
      filter,
      lastSeenBlock: currentHeight,
      lastPolledAt: this.now()
    };
    this.filters.set(id, entry);
    return id;
  }
  /** Create a new block filter. Returns the filter ID. */
  createBlockFilter(currentHeight) {
    this.evictStale();
    if (this.filters.size >= this.maxFilters) {
      throw new Error(`filter store full (max ${this.maxFilters})`);
    }
    const id = this.allocateId();
    const entry = {
      id,
      kind: "newBlock",
      lastSeenBlock: currentHeight,
      lastPolledAt: this.now()
    };
    this.filters.set(id, entry);
    return id;
  }
  /** Look up a filter, or return `null` if it doesn't exist / has expired. */
  get(id) {
    this.evictStale();
    return this.filters.get(id) ?? null;
  }
  /**
   * Mark a filter as freshly polled — call this after returning
   * results. Updates `lastPolledAt` and `lastSeenBlock` so the next
   * poll returns only what's happened since.
   */
  markPolled(id, newLastSeen) {
    const entry = this.filters.get(id);
    if (!entry) return;
    entry.lastSeenBlock = newLastSeen;
    entry.lastPolledAt = this.now();
  }
  /** Remove a filter. Returns true if it existed. */
  uninstall(id) {
    return this.filters.delete(id);
  }
  /** Number of currently-live filters. */
  size() {
    this.evictStale();
    return this.filters.size;
  }
  // -------------------------------------------------------------------------
  // Helpers
  // -------------------------------------------------------------------------
  allocateId() {
    const id = "0x" + this.nextIdCounter.toString(16);
    this.nextIdCounter += 1n;
    return id;
  }
  evictStale() {
    const now = this.now();
    const cutoff = now - this.ttlMs;
    for (const [id, entry] of this.filters) {
      if (entry.lastPolledAt < cutoff) {
        this.filters.delete(id);
      }
    }
  }
};

// src/rpc/eth-rpc.ts
var JsonRpcErrorCode = {
  ParseError: -32700,
  InvalidRequest: -32600,
  MethodNotFound: -32601,
  InvalidParams: -32602,
  InternalError: -32603
};
var MAX_LOG_RANGE = 10000n;
var JsonRpcError = class extends Error {
  constructor(code, message, data) {
    super(message);
    this.code = code;
    this.data = data;
    this.name = "JsonRpcError";
  }
  code;
  data;
};
var EthRpcHandler = class {
  node;
  clientVersion;
  filterStore = new FilterStore();
  constructor(options) {
    this.node = options.node;
    this.clientVersion = options.clientVersion ?? "asentum/v0.0.0-alpha.0";
  }
  /**
   * Dispatch a single JSON-RPC request and return a response.
   * Never throws — errors are wrapped into the JSON-RPC error shape.
   */
  async handle(request) {
    const id = request.id ?? null;
    if (typeof request.method !== "string") {
      return this.errorResponse(
        id,
        JsonRpcErrorCode.InvalidRequest,
        "method must be a string"
      );
    }
    try {
      const result = await this.dispatch(request.method, request.params ?? []);
      return { jsonrpc: "2.0", id, result };
    } catch (err) {
      if (err instanceof JsonRpcError) {
        return {
          jsonrpc: "2.0",
          id,
          error: { code: err.code, message: err.message, data: err.data }
        };
      }
      const message = err instanceof Error ? err.message : String(err);
      return this.errorResponse(id, JsonRpcErrorCode.InternalError, message);
    }
  }
  /**
   * Dispatch a batch of JSON-RPC requests. JSON-RPC 2.0 batch
   * responses are an array in the same order as the requests.
   */
  async handleBatch(batch) {
    return Promise.all(batch.map((req) => this.handle(req)));
  }
  // -------------------------------------------------------------------------
  // Method dispatch
  // -------------------------------------------------------------------------
  async dispatch(method, params) {
    switch (method) {
      case "web3_clientVersion":
        return this.clientVersion;
      case "net_version":
        return this.node.getChainInfo().chainId.toString();
      case "eth_chainId":
        return quantityHex(this.node.getChainInfo().chainId);
      case "eth_syncing":
        return false;
      case "eth_accounts":
        return [];
      case "eth_blockNumber":
        return quantityHex(this.node.getChainInfo().height);
      case "eth_gasPrice":
        return quantityHex(this.node.getChainInfo().latestHeader.baseFeePerGas);
      case "eth_maxPriorityFeePerGas":
        return "0x0";
      case "eth_feeHistory":
        return this.feeHistory(params);
      case "eth_estimateGas":
        return this.estimateGas(params);
      case "eth_getBalance":
        return this.getBalance(params);
      case "eth_getTransactionCount":
        return this.getTransactionCount(params);
      case "eth_getBlockByNumber":
        return this.getBlockByNumber(params);
      case "eth_getBlockByHash":
        return this.getBlockByHash(params);
      case "eth_getBlockTransactionCountByNumber":
        return this.getBlockTransactionCountByNumber(params);
      case "eth_getBlockTransactionCountByHash":
        return this.getBlockTransactionCountByHash(params);
      case "eth_getTransactionByHash":
        return this.getTransactionByHash(params);
      case "eth_getTransactionReceipt":
        return this.getTransactionReceipt(params);
      case "eth_getCode":
        return this.getCode(params);
      case "eth_getStorageAt":
        return this.getStorageAt(params);
      case "eth_call":
        return this.ethCall(params);
      case "eth_getLogs":
        return this.getLogs(params);
      case "eth_newFilter":
        return this.newFilter(params);
      case "eth_newBlockFilter":
        return this.newBlockFilter();
      case "eth_getFilterLogs":
        return this.getFilterLogs(params);
      case "eth_getFilterChanges":
        return this.getFilterChanges(params);
      case "eth_uninstallFilter":
        return this.uninstallFilter(params);
      default:
        throw new JsonRpcError(
          JsonRpcErrorCode.MethodNotFound,
          `method not found: ${method}`
        );
    }
  }
  // -------------------------------------------------------------------------
  // Method implementations
  // -------------------------------------------------------------------------
  async getBalance(params) {
    const addressHex = params[0];
    if (typeof addressHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getBalance: first param must be an address hex string"
      );
    }
    const addr = hexToAddress20(addressHex);
    const { balance } = await this.node.getBalance(addr);
    return quantityHex(balance);
  }
  async getTransactionCount(params) {
    const addressHex = params[0];
    if (typeof addressHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getTransactionCount: first param must be an address hex string"
      );
    }
    const addr = hexToAddress20(addressHex);
    const { nonce } = await this.node.getBalance(addr);
    return quantityHex(nonce);
  }
  async getBlockByNumber(params) {
    const blockParam = params[0];
    const includeFullTx = Boolean(params[1]);
    const currentHeight = this.node.getChainInfo().height;
    const resolved = parseBlockParam(blockParam, currentHeight);
    if (resolved.number === null) {
      return null;
    }
    const block = await this.node.getBlock(resolved.number);
    if (!block) return null;
    return blockToEthJson(block, includeFullTx);
  }
  async getBlockByHash(params) {
    const hashHex = params[0];
    const includeFullTx = Boolean(params[1]);
    if (typeof hashHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getBlockByHash: first param must be a 32-byte hex hash"
      );
    }
    const hashBytes = hexToBytes2(hashHex);
    if (hashBytes.length !== 32) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `eth_getBlockByHash: expected 32-byte hash, got ${hashBytes.length}`
      );
    }
    const block = await this.node.getBlockByHash(hashBytes);
    if (!block) return null;
    return blockToEthJson(block, includeFullTx);
  }
  async getBlockTransactionCountByNumber(params) {
    const blockParam = params[0];
    const currentHeight = this.node.getChainInfo().height;
    const resolved = parseBlockParam(blockParam, currentHeight);
    if (resolved.number === null) return null;
    const block = await this.node.getBlock(resolved.number);
    if (!block) return null;
    return quantityHex(block.transactions.length);
  }
  async getBlockTransactionCountByHash(params) {
    const hashHex = params[0];
    if (typeof hashHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getBlockTransactionCountByHash: first param must be a 32-byte hex hash"
      );
    }
    const hashBytes = hexToBytes2(hashHex);
    if (hashBytes.length !== 32) return null;
    const block = await this.node.getBlockByHash(hashBytes);
    if (!block) return null;
    return quantityHex(block.transactions.length);
  }
  async getTransactionByHash(params) {
    const hashHex = params[0];
    if (typeof hashHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getTransactionByHash: first param must be a 32-byte hex hash"
      );
    }
    const hashBytes = hexToBytes2(hashHex);
    if (hashBytes.length !== 32) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `eth_getTransactionByHash: expected 32-byte hash, got ${hashBytes.length}`
      );
    }
    const receipt = await this.node.getReceiptByTxHash(hashBytes);
    if (!receipt) return null;
    const block = await this.node.getBlock(receipt.blockNumber);
    if (!block) return null;
    const tx = block.transactions[receipt.txIndex];
    if (!tx) return null;
    const blockHashHex = bytesToHex8(computeBlockHash(block.header));
    return txToEthJson(tx, {
      blockHashHex,
      blockNumberHex: quantityHex(block.header.number),
      transactionIndex: receipt.txIndex
    });
  }
  async getTransactionReceipt(params) {
    const hashHex = params[0];
    if (typeof hashHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getTransactionReceipt: first param must be a 32-byte hex hash"
      );
    }
    const hashBytes = hexToBytes2(hashHex);
    if (hashBytes.length !== 32) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `eth_getTransactionReceipt: expected 32-byte hash, got ${hashBytes.length}`
      );
    }
    const receipt = await this.node.getReceiptByTxHash(hashBytes);
    if (!receipt) return null;
    const block = await this.node.getBlock(receipt.blockNumber);
    const blockHashHex = block ? bytesToHex8(computeBlockHash(block.header)) : "0x" + "00".repeat(32);
    return receiptToEthJson(receipt, blockHashHex);
  }
  async getCode(params) {
    const addressHex = params[0];
    if (typeof addressHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getCode: first param must be an address hex string"
      );
    }
    const addr = hexToAddress20(addressHex);
    const code = await this.node.getContractCode(addr);
    if (!code || code.length === 0) return "0x";
    return bytesToHex8(code);
  }
  async getStorageAt(params) {
    const addressHex = params[0];
    const keyHex = params[1];
    if (typeof addressHex !== "string" || typeof keyHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_getStorageAt: params must be [address, key, blockTag?]"
      );
    }
    const addr = hexToAddress20(addressHex);
    const value = await this.node.getContractStorageValue(addr, keyHex);
    if (value === null) return "0x";
    const bytes = new TextEncoder().encode(value);
    return bytesToHex8(bytes);
  }
  /**
   * `eth_getLogs`: scans blocks in the filter's range, flattens
   * receipts' events into Ethereum-shape logs, returns those matching
   * address + topics. Enforces MAX_LOG_RANGE.
   */
  async getLogs(params) {
    const currentHeight = this.node.getChainInfo().height;
    let filter;
    try {
      filter = parseLogFilter(params[0], currentHeight);
    } catch (err) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `eth_getLogs: ${err instanceof Error ? err.message : String(err)}`
      );
    }
    return this.scanLogsInRange(filter.fromBlock, filter.toBlock, filter);
  }
  /**
   * `eth_newFilter`: create a stateful log filter, get back an opaque
   * ID. Use `eth_getFilterLogs` / `eth_getFilterChanges` to poll.
   * Filters auto-expire after 5 minutes of inactivity.
   */
  newFilter(params) {
    const currentHeight = this.node.getChainInfo().height;
    let filter;
    try {
      filter = parseLogFilter(params[0], currentHeight);
    } catch (err) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `eth_newFilter: ${err instanceof Error ? err.message : String(err)}`
      );
    }
    try {
      return this.filterStore.createLogFilter(filter, currentHeight);
    } catch (err) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InternalError,
        err instanceof Error ? err.message : String(err)
      );
    }
  }
  newBlockFilter() {
    const currentHeight = this.node.getChainInfo().height;
    try {
      return this.filterStore.createBlockFilter(currentHeight);
    } catch (err) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InternalError,
        err instanceof Error ? err.message : String(err)
      );
    }
  }
  async getFilterLogs(params) {
    const id = requireString(params[0], "eth_getFilterLogs: filter id");
    const entry = this.filterStore.get(id);
    if (!entry) {
      throw new JsonRpcError(JsonRpcErrorCode.InvalidParams, `filter not found: ${id}`);
    }
    if (entry.kind !== "log") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `filter ${id} is not a log filter`
      );
    }
    return this.scanLogsInRange(
      entry.filter.fromBlock,
      entry.filter.toBlock,
      entry.filter
    );
  }
  /**
   * Return what's NEW since the last poll:
   *   - log filters: logs in (lastSeen, currentHeight]
   *   - block filters: block hashes in (lastSeen, currentHeight]
   * Updates the filter's `lastSeenBlock` watermark before returning.
   */
  async getFilterChanges(params) {
    const id = requireString(params[0], "eth_getFilterChanges: filter id");
    const entry = this.filterStore.get(id);
    if (!entry) {
      throw new JsonRpcError(JsonRpcErrorCode.InvalidParams, `filter not found: ${id}`);
    }
    const currentHeight = this.node.getChainInfo().height;
    const from = entry.lastSeenBlock + 1n;
    const to = currentHeight;
    if (to < from) {
      this.filterStore.markPolled(id, currentHeight);
      return [];
    }
    if (entry.kind === "log") {
      const logs = await this.scanLogsInRange(from, to, entry.filter);
      this.filterStore.markPolled(id, to);
      return logs;
    }
    const hashes = [];
    for (let n = from; n <= to; n++) {
      const block = await this.node.getBlock(n);
      if (!block) continue;
      hashes.push(bytesToHex8(computeBlockHash(block.header)));
    }
    this.filterStore.markPolled(id, to);
    return hashes;
  }
  uninstallFilter(params) {
    const id = requireString(params[0], "eth_uninstallFilter: filter id");
    return this.filterStore.uninstall(id);
  }
  /**
   * Internal helper: scan a block range, flatten + filter logs.
   * Shared between `eth_getLogs`, `eth_getFilterLogs`, and
   * `eth_getFilterChanges`. Enforces the MAX_LOG_RANGE cap.
   */
  async scanLogsInRange(fromBlock, toBlock, filter) {
    const rangeWidth = toBlock - fromBlock + 1n;
    if (rangeWidth > MAX_LOG_RANGE) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `log scan: requested range ${rangeWidth} exceeds ${MAX_LOG_RANGE}-block limit`
      );
    }
    const out = [];
    for (let n = fromBlock; n <= toBlock; n++) {
      const block = await this.node.getBlock(n);
      if (!block) continue;
      const receipts = await this.node.getReceiptsByBlock(n);
      if (receipts.length === 0) continue;
      const blockHashHex = bytesToHex8(computeBlockHash(block.header));
      for (const receipt of receipts) {
        const logs = receiptEventsToLogs(receipt, blockHashHex);
        for (const log of logs) {
          if (matchesFilter(log, filter)) {
            out.push(log);
          }
        }
      }
    }
    return out;
  }
  async ethCall(params) {
    const call = params[0];
    if (!call || typeof call !== "object") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_call: first param must be a call object"
      );
    }
    const callObj = call;
    const toHex3 = callObj.to;
    const dataHex = callObj.data;
    if (typeof toHex3 !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        'eth_call: missing "to" address'
      );
    }
    const toBytes = hexToAddress20(toHex3);
    if (typeof dataHex !== "string" || dataHex.length === 0 || dataHex === "0x") {
      return "0x";
    }
    let decoded;
    try {
      const bytes = hexToBytes2(dataHex);
      decoded = JSON.parse(new TextDecoder("utf-8").decode(bytes));
    } catch (err) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        `eth_call: data field must be hex-encoded JSON {method, args}: ${String(err)}`
      );
    }
    if (typeof decoded.method !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        "eth_call: decoded data missing string `method` field"
      );
    }
    const args = Array.isArray(decoded.args) ? decoded.args : [];
    const result = await this.node.viewCall(toBytes, decoded.method, args);
    if (!result.ok) {
      throw new JsonRpcError(
        JsonRpcErrorCode.InternalError,
        `eth_call failed: ${result.reason}`
      );
    }
    const encoded = new TextEncoder().encode(
      JSON.stringify(
        result.returnValue ?? null,
        (_k, v) => typeof v === "bigint" ? v.toString() : v
      )
    );
    return bytesToHex8(encoded);
  }
  feeHistory(params) {
    let blockCount = 0;
    try {
      const raw = params[0];
      if (typeof raw === "string") blockCount = Number(hexToBigint(raw));
      else if (typeof raw === "number") blockCount = raw;
    } catch {
      blockCount = 0;
    }
    if (blockCount < 0) blockCount = 0;
    if (blockCount > 1024) blockCount = 1024;
    const currentHeight = this.node.getChainInfo().height;
    const baseFeePerGas = new Array(blockCount + 1).fill("0x1");
    const gasUsedRatio = new Array(blockCount).fill(0);
    return {
      oldestBlock: quantityHex(
        currentHeight - BigInt(blockCount) > 0n ? currentHeight - BigInt(blockCount) + 1n : 0n
      ),
      baseFeePerGas,
      gasUsedRatio,
      reward: Array.isArray(params[2]) ? new Array(blockCount).fill(
        new Array(params[2].length).fill("0x0")
      ) : void 0
    };
  }
  estimateGas(_params) {
    return "0x186a0";
  }
  // -------------------------------------------------------------------------
  // Helpers
  // -------------------------------------------------------------------------
  errorResponse(id, code, message) {
    return { jsonrpc: "2.0", id, error: { code, message } };
  }
};
function requireString(value, context) {
  if (typeof value !== "string") {
    throw new JsonRpcError(
      JsonRpcErrorCode.InvalidParams,
      `${context} must be a string`
    );
  }
  return value;
}

// src/rpc/rate-limit.ts
var RateLimiter = class {
  buckets = /* @__PURE__ */ new Map();
  capacity;
  refillPerSecond;
  now;
  maxKeys;
  constructor(opts) {
    if (opts.capacity <= 0) {
      throw new Error("RateLimiter: capacity must be > 0");
    }
    if (opts.refillPerSecond <= 0) {
      throw new Error("RateLimiter: refillPerSecond must be > 0");
    }
    this.capacity = opts.capacity;
    this.refillPerSecond = opts.refillPerSecond;
    this.now = opts.now ?? Date.now;
    this.maxKeys = opts.maxKeys ?? 1e4;
  }
  /**
   * Try to consume one token for the given key.
   * Returns `{ok: true}` if allowed, or `{ok: false, retryAfterMs}` if
   * the bucket is empty. `retryAfterMs` is how long until the next
   * token is available — use it for the `Retry-After` HTTP header.
   */
  consume(key6) {
    const now = this.now();
    let bucket = this.buckets.get(key6);
    if (!bucket) {
      if (this.buckets.size >= this.maxKeys) {
        const firstKey = this.buckets.keys().next().value;
        if (firstKey !== void 0) this.buckets.delete(firstKey);
      }
      bucket = { tokens: this.capacity, lastRefilledAt: now };
      this.buckets.set(key6, bucket);
    } else {
      const elapsedSec = (now - bucket.lastRefilledAt) / 1e3;
      const refill = elapsedSec * this.refillPerSecond;
      bucket.tokens = Math.min(this.capacity, bucket.tokens + refill);
      bucket.lastRefilledAt = now;
    }
    if (bucket.tokens < 1) {
      const missing = 1 - bucket.tokens;
      const retryAfterMs = Math.ceil(missing / this.refillPerSecond * 1e3);
      return { ok: false, retryAfterMs };
    }
    bucket.tokens -= 1;
    return { ok: true };
  }
  /** Number of keys currently tracked. */
  size() {
    return this.buckets.size;
  }
  /** Drop all state. */
  reset() {
    this.buckets.clear();
  }
};

// src/rpc/server.ts
init_v3();
import { existsSync, readFileSync } from "fs";
var RpcServer = class _RpcServer {
  constructor(options) {
    this.options = options;
    this.ethRpc = new EthRpcHandler({ node: options.node });
    this.corsOrigin = options.corsOrigin ?? "*";
    this.faucetLimiter = options.faucetRateLimit ? new RateLimiter({
      capacity: options.faucetRateLimit.capacity ?? 1,
      refillPerSecond: options.faucetRateLimit.refillPerSecond ?? 1 / 60,
      now: options.now
    }) : null;
    this.v3PrecommitLimiter = new RateLimiter({
      capacity: 20,
      refillPerSecond: 10,
      now: options.now
    });
  }
  options;
  server;
  listeningPort = 0;
  ethRpc;
  corsOrigin;
  faucetLimiter;
  /**
   * Hot-path cache for /validators. Under 13-committee prod, worker
   * daemons + Operator apps + explorer poll this every few seconds and
   * the handler does 3+ state reads per call. A short TTL absorbs the
   * duplicate work without hiding real state changes for more than a
   * block time. Producer + external RPC readers only see stale data
   * during the TTL window; anything longer than a block wouldn't be
   * safe (committee membership can flip epoch-to-epoch).
   */
  validatorsCache = null;
  static VALIDATORS_CACHE_MS = 500;
  /**
   * Per-IP rate limiter for the internet-facing POST /v3/precommit endpoint
   * (v3 pull-mode). Bounds precommit spam from any single source. Generous —
   * an honest external posts at most a few precommits/second (one per height),
   * so 20 tokens + 10/s refill never throttles a real validator but caps a
   * flood. Always constructed; the endpoint enforces it unconditionally.
   */
  v3PrecommitLimiter;
  /**
   * Resolve the client IP for rate limiting. Trusts the first entry
   * in `X-Forwarded-For` if present (for reverse proxy support),
   * falling back to the raw socket address otherwise.
   */
  clientIp(req) {
    const xff = req.headers["x-forwarded-for"];
    if (typeof xff === "string" && xff.length > 0) {
      const first = xff.split(",")[0]?.trim();
      if (first) return first;
    }
    return req.socket.remoteAddress ?? "unknown";
  }
  /**
   * Hardcoded baseline of confirmed faucet abusers from before the
   * file-based denylist existed. Operators add new entries to the
   * JSON file rather than touching this list.
   */
  static FAUCET_BASELINE_DENY = Object.freeze({
    addresses: [
      // 5M bonded, signedBlocks=0 since h=3119, 54 receives consolidated
      // into a single wallet farming /dev/faucet via CLI.
      "0x781453a5dce5938ff4c13eaabdeaa26743fe6815",
      "0x838043849c53ac2953dc7139946de0cd61c37ec5"
    ],
    ips: []
  });
  faucetDenylistLogged = false;
  /**
   * Read the faucet denylist JSON file fresh on every request. The file
   * is tiny so the cost is one stat + small read per drip. Operators
   * edit the file directly to add/remove abusers; no restart needed.
   *
   * Returns the union of the file contents and the hardcoded baseline.
   * Missing or malformed file = baseline only, logged once per process.
   */
  loadFaucetDenylist() {
    const addresses = /* @__PURE__ */ new Set();
    const ips = /* @__PURE__ */ new Set();
    for (const a of _RpcServer.FAUCET_BASELINE_DENY.addresses) addresses.add(a);
    for (const i of _RpcServer.FAUCET_BASELINE_DENY.ips) ips.add(i);
    const dataDir = process.env.ASENTUM_DATA_DIR ?? "/opt/asentum/data";
    const path3 = `${dataDir}/faucet-denylist.json`;
    try {
      if (!existsSync(path3)) return { addresses, ips };
      const raw = readFileSync(path3, "utf8");
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed.addresses)) {
        for (const entry of parsed.addresses) {
          if (typeof entry === "string" && entry.length > 0) {
            addresses.add(entry.toLowerCase());
          }
        }
      }
      if (Array.isArray(parsed.ips)) {
        for (const entry of parsed.ips) {
          if (typeof entry === "string" && entry.length > 0) {
            ips.add(entry);
          }
        }
      }
    } catch (err) {
      if (!this.faucetDenylistLogged) {
        this.faucetDenylistLogged = true;
        console.warn(`[faucet] denylist file at ${path3} unreadable, using baseline only: ${err.message}`);
      }
    }
    return { addresses, ips };
  }
  /** Emit CORS headers on every response. */
  applyCors(res) {
    res.setHeader("Access-Control-Allow-Origin", this.corsOrigin);
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    res.setHeader(
      "Access-Control-Allow-Headers",
      "Content-Type, Accept, X-Requested-With"
    );
    res.setHeader("Access-Control-Max-Age", "86400");
    res.setHeader("Vary", "Origin");
  }
  async start() {
    return new Promise((resolve, reject) => {
      const server = createServer2((req, res) => {
        void this.handle(req, res);
      });
      server.on("error", reject);
      server.listen(this.options.port, this.options.host ?? "127.0.0.1", () => {
        const addr = server.address();
        if (addr && typeof addr === "object") {
          this.listeningPort = addr.port;
          this.server = server;
          resolve({ host: this.options.host ?? "127.0.0.1", port: addr.port });
        } else {
          reject(new Error("unexpected server address"));
        }
      });
    });
  }
  async stop() {
    if (!this.server) return;
    const server = this.server;
    this.server = void 0;
    return new Promise((resolve, reject) => {
      server.close((err) => err ? reject(err) : resolve());
    });
  }
  port() {
    return this.listeningPort;
  }
  async handle(req, res) {
    try {
      const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
      const method = req.method ?? "GET";
      const path3 = url.pathname;
      if (method === "OPTIONS") {
        this.applyCors(res);
        res.statusCode = 204;
        res.end();
        return;
      }
      if (method === "GET" && path3 === "/") {
        return this.html(res, 200, EXPLORER_HTML);
      }
      if (method === "GET" && path3 === "/playground") {
        return this.html(res, 200, PLAYGROUND_HTML);
      }
      if (method === "GET" && path3.startsWith("/block/") && !path3.endsWith("/receipts")) {
        const rest = path3.slice("/block/".length);
        if (/^\d+\/?$/.test(rest)) {
          const dest = req.headers["sec-fetch-dest"];
          const accept = String(req.headers.accept ?? "");
          const wantsHtml = dest === "document" || !dest && accept.includes("text/html");
          if (wantsHtml) {
            return this.html(res, 200, EXPLORER_HTML);
          }
        }
      }
      if (method === "GET" && path3.startsWith("/address/")) {
        const rest = path3.slice("/address/".length);
        const looksLikeAddr = /^0x[0-9a-fA-F]{40}\/?$/.test(rest) || /^ase1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38,58}\/?$/i.test(rest);
        if (looksLikeAddr) {
          const dest = req.headers["sec-fetch-dest"];
          const accept = String(req.headers.accept ?? "");
          const wantsHtml = dest === "document" || !dest && accept.includes("text/html");
          if (wantsHtml) {
            return this.html(res, 200, EXPLORER_HTML);
          }
        }
      }
      if (method === "GET" && path3.startsWith("/tx/")) {
        const rest = path3.slice("/tx/".length);
        if (/^0x[0-9a-fA-F]{64}\/?$/.test(rest)) {
          const dest = req.headers["sec-fetch-dest"];
          const accept = String(req.headers.accept ?? "");
          const wantsHtml = dest === "document" || !dest && accept.includes("text/html");
          if (wantsHtml) {
            return this.html(res, 200, EXPLORER_HTML);
          }
        }
      }
      if (method === "GET" && (path3 === "/governance" || path3 === "/governance/" || path3 === "/governance/libraries" || path3 === "/governance/libraries/")) {
        const dest = req.headers["sec-fetch-dest"];
        const accept = String(req.headers.accept ?? "");
        const wantsHtml = dest === "document" || !dest && accept.includes("text/html");
        if (wantsHtml) {
          return this.html(res, 200, EXPLORER_HTML);
        }
      }
      if (method === "GET" && path3.startsWith("/governance/") && /^\/governance\/\d+\/?$/.test(path3)) {
        const dest = req.headers["sec-fetch-dest"];
        const accept = String(req.headers.accept ?? "");
        const wantsHtml = dest === "document" || !dest && accept.includes("text/html");
        if (wantsHtml) {
          return this.html(res, 200, EXPLORER_HTML);
        }
      }
      if (method === "POST" && path3 === "/") {
        const body = await readJsonBodyRaw(req);
        return this.json(res, 200, await this.handleJsonRpc(body));
      }
      if (method === "GET" && path3 === "/api") {
        return this.json(res, 200, this.root());
      }
      if (method === "GET" && path3 === "/metadata") {
        return this.json(res, 200, this.metadata(req));
      }
      if (method === "GET" && path3 === "/genesis") {
        return this.json(res, 200, this.genesisSpecResponse());
      }
      if (method === "GET" && path3.startsWith("/raw-block/")) {
        const numStr = path3.slice("/raw-block/".length).replace(/\/$/, "");
        if (!/^\d+$/.test(numStr)) {
          return this.json(res, 400, { error: "invalid block number" });
        }
        const block = await this.options.node.getBlock(BigInt(numStr));
        if (!block) {
          return this.json(res, 404, { error: "block not found" });
        }
        const bytes = BlockSchema4.serialize(block);
        return this.json(res, 200, { rawBlock: bytesToHex7(bytes) });
      }
      if (method === "GET" && path3 === "/health") {
        return this.json(res, 200, { status: "ok" });
      }
      if (method === "GET" && path3 === "/ready") {
        return this.readyProbe(res);
      }
      if (method === "GET" && path3 === "/chain") {
        return this.json(res, 200, this.chainInfo());
      }
      if (method === "GET" && path3 === "/libp2p-addrs") {
        return this.json(res, 200, this.libp2pAddrs());
      }
      if (method === "GET" && path3 === "/validators") {
        return this.json(res, 200, await this.validators());
      }
      if (method === "GET" && path3 === "/participation") {
        return this.json(res, 200, await this.participation(url.searchParams));
      }
      if (method === "GET" && path3 === "/probe/reachable") {
        return this.json(res, 200, await this.probeReachable(url.searchParams));
      }
      if (method === "GET" && path3 === "/consensus/liveness") {
        return this.json(res, 200, this.options.node.getLivenessSnapshot());
      }
      if (method === "GET" && path3 === "/debug/state") {
        if (process.env.ASENTUM_DEBUG_ENDPOINTS !== "1") {
          return this.json(res, 404, { error: "not found" });
        }
        return this.json(res, 200, await this.options.node.dumpStateForDebug());
      }
      if (method === "GET" && path3 === "/governance/proposals") {
        return this.json(res, 200, await this.governanceProposals());
      }
      if (method === "GET" && path3.startsWith("/governance/proposal/")) {
        const idStr = path3.slice("/governance/proposal/".length);
        return this.json(res, 200, await this.governanceProposal(idStr));
      }
      if (method === "GET" && path3 === "/governance/libraries") {
        return this.json(res, 200, await this.governanceLibraries());
      }
      if (method === "GET" && path3.startsWith("/governance/library/")) {
        const name = path3.slice("/governance/library/".length);
        return this.json(res, 200, await this.governanceLibrary(name));
      }
      if (method === "GET" && path3.startsWith("/governance/locked/")) {
        const addr = path3.slice("/governance/locked/".length);
        return this.json(res, 200, await this.governanceLocked(addr));
      }
      if (method === "GET" && path3 === "/slashing/tombstones") {
        return this.json(res, 200, await this.tombstones());
      }
      if (method === "GET" && path3 === "/consensus/state") {
        return this.json(res, 200, this.consensusState());
      }
      if (method === "POST" && path3 === "/consensus/vote") {
        const body = await readJsonBody(req);
        return this.json(res, 200, await this.submitVote(body));
      }
      if (method === "POST" && path3 === "/consensus/proposal") {
        const body = await readJsonBody(req);
        const result = await this.submitProposal(body);
        console.log(`[/consensus/proposal] received \u2192 accepted=${result.accepted ?? false}${result.reason ? ` reason="${result.reason}"` : ""}${result.appliedHeight ? ` h=${result.appliedHeight}` : ""}`);
        return this.json(res, 200, result);
      }
      if (method === "GET" && path3 === "/consensus/proposal") {
        const url2 = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
        const heightStr = url2.searchParams.get("height");
        const roundStr = url2.searchParams.get("round");
        let height;
        let round;
        try {
          if (heightStr !== null) height = BigInt(heightStr);
          if (roundStr !== null) round = BigInt(roundStr);
        } catch {
          return this.json(res, 400, { available: false, error: "invalid height/round" });
        }
        const cached = this.options.node.getCachedProposal(height, round);
        if (!cached) {
          return this.json(res, 200, { available: false });
        }
        return this.json(res, 200, {
          available: true,
          height: cached.height.toString(),
          round: cached.round.toString(),
          proposer: cached.proposerHex,
          rawBlock: bytesToHex7(cached.rawBlock)
        });
      }
      if (method === "GET" && path3 === "/consensus/votes") {
        const url2 = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
        const heightStr = url2.searchParams.get("height");
        const roundStr = url2.searchParams.get("round");
        const kindStr = url2.searchParams.get("kind");
        if (heightStr === null || roundStr === null || kindStr === null) {
          return this.json(res, 400, { available: false, error: "height, round, and kind are required" });
        }
        if (kindStr !== "prevote" && kindStr !== "precommit") {
          return this.json(res, 400, { available: false, error: "kind must be prevote or precommit" });
        }
        let height;
        let round;
        try {
          height = BigInt(heightStr);
          round = BigInt(roundStr);
        } catch {
          return this.json(res, 400, { available: false, error: "invalid height/round" });
        }
        const votes = this.options.node.getCurrentRoundVotes(kindStr, height, round);
        if (votes === null) {
          return this.json(res, 200, { available: false });
        }
        return this.json(res, 200, {
          available: true,
          height: height.toString(),
          round: round.toString(),
          kind: kindStr,
          votes: votes.map((v) => bytesToHex7(SignedVoteSchema4.serialize(v)))
        });
      }
      if (method === "GET" && path3 === "/v3/proposal") {
        const heightStr = url.searchParams.get("height");
        if (heightStr === null) {
          return this.json(res, 400, { available: false, error: "height is required" });
        }
        let height;
        try {
          height = BigInt(heightStr);
        } catch {
          return this.json(res, 400, { available: false, error: "invalid height" });
        }
        const served = await this.options.node.getV3ProposalWire(height);
        if (!served) {
          res.statusCode = 204;
          this.applyCors(res);
          res.end();
          return;
        }
        return this.json(res, 200, {
          available: true,
          height: height.toString(),
          wire: bytesToHex7(served.wire),
          auraHash: bytesToHex7(served.auraHash)
        });
      }
      if (method === "POST" && path3 === "/v3/precommit") {
        const clientIp = req.socket.remoteAddress ?? "unknown";
        const rl = this.v3PrecommitLimiter.consume(clientIp);
        if (!rl.ok) {
          res.setHeader("Retry-After", String(Math.ceil(rl.retryAfterMs / 1e3)));
          return this.json(res, 429, {
            accepted: false,
            reason: `rate limited \u2014 retry in ${Math.ceil(rl.retryAfterMs / 1e3)}s`
          });
        }
        let body;
        try {
          body = await readJsonBody(req, V3_PRECOMMIT_MAX_BODY_BYTES);
        } catch (err) {
          if (err instanceof BodyTooLargeError) {
            return this.json(res, 413, { accepted: false, reason: "body too large" });
          }
          throw err;
        }
        return this.json(res, 200, await this.submitV3Precommit(body));
      }
      if (method === "GET" && path3 === "/v3/finalized") {
        const heightStr = url.searchParams.get("height");
        if (heightStr === null) {
          return this.json(res, 400, { error: "height is required" });
        }
        let height;
        try {
          height = BigInt(heightStr);
        } catch {
          return this.json(res, 400, { error: "invalid height" });
        }
        const info = this.options.node.getV3FinalizedInfo(height);
        return this.json(res, 200, {
          height: height.toString(),
          finalized: info.finalized,
          auraHash: info.auraHash ? bytesToHex7(info.auraHash) : void 0
        });
      }
      if (method === "GET" && path3 === "/v3/heads") {
        const heads = this.options.node.getV3Heads();
        return this.json(res, 200, {
          finalized: heads.finalized.toString(),
          tip: heads.tip.toString()
        });
      }
      if (method === "GET" && path3 === "/v3/commit") {
        const heightStr = url.searchParams.get("height");
        if (heightStr === null) {
          return this.json(res, 400, { error: "height is required" });
        }
        let height;
        try {
          height = BigInt(heightStr);
        } catch {
          return this.json(res, 400, { error: "invalid height" });
        }
        const commit = this.options.node.getV3Commit(height);
        if (!commit) {
          res.statusCode = 204;
          this.applyCors(res);
          res.end();
          return;
        }
        return this.json(res, 200, { height: height.toString(), commit });
      }
      if (method === "GET" && path3 === "/v3/vote-metrics") {
        const metrics = this.options.node.getV3VoteMetrics();
        if (!metrics) return this.json(res, 404, { error: "v3 not engaged" });
        return this.json(res, 200, metrics);
      }
      if (method === "GET" && path3.startsWith("/balance/")) {
        const addressHex = path3.slice("/balance/".length);
        return this.json(res, 200, await this.balance(addressHex));
      }
      if (method === "GET" && path3.startsWith("/asentum/account/")) {
        const addressHex = path3.slice("/asentum/account/".length);
        return this.json(res, 200, await this.accountFull(addressHex));
      }
      if (method === "GET" && path3 === "/cron-status") {
        return this.json(res, 200, {
          enabled: process.env.ASENTUM_DISABLE_CRON !== "1",
          envVar: process.env.ASENTUM_DISABLE_CRON ?? null
        });
      }
      if (method === "GET" && path3.startsWith("/block-raw/")) {
        const numberStr = path3.slice("/block-raw/".length);
        return this.json(res, 200, await this.blockRaw(numberStr));
      }
      if (method === "GET" && path3.startsWith("/block/") && path3.endsWith("/receipts")) {
        const numberStr = path3.slice("/block/".length, -"/receipts".length);
        return this.json(res, 200, await this.blockReceipts(numberStr));
      }
      if (method === "GET" && path3.startsWith("/block/")) {
        const numberStr = path3.slice("/block/".length);
        return this.json(res, 200, await this.block(numberStr));
      }
      if (method === "GET" && path3.startsWith("/receipts/")) {
        const txHashHex = path3.slice("/receipts/".length);
        return this.json(res, 200, await this.receiptByHash(txHashHex));
      }
      if (method === "POST" && path3 === "/tx") {
        const body = await readJsonBody(req);
        return this.json(res, 200, this.submitTx(body));
      }
      if (method === "POST" && path3 === "/tx/batch") {
        const body = await readJsonBody(req);
        if (!Array.isArray(body?.transactions)) {
          return this.json(res, 400, { error: "expected { transactions: [{ rawTx }] }" });
        }
        const results = body.transactions.map((tx) => {
          try {
            return this.submitTx(tx);
          } catch (err) {
            return { accepted: false, reason: String(err) };
          }
        });
        const accepted = results.filter((r) => r.accepted).length;
        return this.json(res, 200, { accepted, rejected: results.length - accepted, total: results.length, results });
      }
      if (method === "POST" && path3 === "/view") {
        const body = await readJsonBody(req);
        return this.json(res, 200, await this.viewCall(body));
      }
      if (method === "POST" && path3 === "/dev/faucet") {
        const denylist = this.loadFaucetDenylist();
        const bodyEarly = await readJsonBody(req);
        const toLower = String(bodyEarly?.to ?? "").toLowerCase();
        if (denylist.addresses.has(toLower)) {
          return this.json(res, 200, {
            accepted: false,
            reason: "address suspended from faucet \u2014 DM @asentum_network if you think this is wrong"
          });
        }
        const clientIp = this.clientIp(req);
        if (denylist.ips.has(clientIp)) {
          return this.json(res, 200, {
            accepted: false,
            reason: "IP suspended from faucet \u2014 DM @asentum_network if you think this is wrong"
          });
        }
        if (this.faucetLimiter) {
          const check = this.faucetLimiter.consume(clientIp);
          if (!check.ok) {
            res.setHeader(
              "Retry-After",
              String(Math.ceil(check.retryAfterMs / 1e3))
            );
            return this.json(res, 429, {
              accepted: false,
              reason: `rate limited \u2014 try again in ${Math.ceil(check.retryAfterMs / 1e3)}s`,
              retryAfterMs: check.retryAfterMs
            });
          }
        }
        return this.json(res, 200, await this.faucet(bodyEarly));
      }
      if (method === "POST" && path3 === "/dev/produce-block") {
        const block = await this.options.node.produceBlockNow();
        if (!block) {
          return this.json(res, 200, { skipped: true, reason: "not the scheduled proposer" });
        }
        return this.json(res, 200, encodeBlock(block));
      }
      return this.json(res, 404, { error: "not found", method, path: path3 });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      console.error("[rpc] unhandled error:", err);
      this.json(res, 500, { error: "internal", message });
    }
  }
  /**
   * Return chain metadata in the shape MetaMask's
   * `wallet_addEthereumChain` (EIP-3085) expects.
   *
   * Public URLs can be configured via RpcServerOptions. If not set,
   * they are synthesized from the incoming request's `Host` header.
   * immediately without any setup.
   */
  /**
   * Readiness probe. Returns 200 if the chain has advanced in the
   * last ~30 seconds (block production is healthy), 503 otherwise.
   * The threshold is generous so a few missed blocks don't pull
   * the node from rotation.
   */
  readyProbe(res) {
    const info = this.options.node.getChainInfo();
    const nowSec = BigInt(Math.floor(Date.now() / 1e3));
    const headerTs = info.latestHeader.timestamp;
    const ageSec = nowSec > headerTs ? Number(nowSec - headerTs) : 0;
    const thresholdSec = 30;
    const fresh = info.height === 0n || ageSec <= thresholdSec;
    if (fresh) {
      return this.json(res, 200, {
        status: "ready",
        height: info.height.toString(),
        lastBlockAgeSec: ageSec
      });
    }
    return this.json(res, 503, {
      status: "stale",
      height: info.height.toString(),
      lastBlockAgeSec: ageSec,
      thresholdSec
    });
  }
  /**
   * Return the chain's genesis spec for joining validators. Returns
   * an error if this node doesn't have a spec on disk.
   */
  genesisSpecResponse() {
    const spec = this.options.node.getGenesisSpec();
    if (!spec) {
      return {
        error: "no genesis spec available",
        message: "this node does not have a persisted genesis spec. re-initialize the node to enable validator joining."
      };
    }
    return spec;
  }
  metadata(req) {
    const info = this.options.node.getChainInfo();
    const host = req.headers.host ?? `localhost:${this.listeningPort}`;
    const protocol = req.headers["x-forwarded-proto"] ?? "http";
    const inferredUrl = `${protocol}://${host}`;
    const rpcUrl = this.options.publicRpcUrl ?? inferredUrl;
    const explorerUrl = this.options.publicExplorerUrl ?? inferredUrl;
    const chainName = this.options.chainName ?? `AsentumChain (${info.chainId})`;
    return {
      chainId: "0x" + info.chainId.toString(16),
      chainName,
      nativeCurrency: { name: "Asentum", symbol: "ASE", decimals: 18 },
      rpcUrls: [rpcUrl],
      blockExplorerUrls: [explorerUrl],
      // Extra fields the EIP-3085 schema doesn't require but a
      // landing page might want for display.
      chainIdDecimal: info.chainId.toString(),
      currentBlock: info.height.toString(),
      faucetUrl: inferredUrl + "/dev/faucet"
    };
  }
  root() {
    const info = this.options.node.getChainInfo();
    return {
      name: "asentum-node",
      version: "0.0.0-alpha.0",
      chainId: info.chainId.toString(),
      height: info.height.toString(),
      faucetAddress: bytesToDisplayAddress(this.options.node.getFaucetAddress())
    };
  }
  chainInfo() {
    const info = this.options.node.getChainInfo();
    return {
      chainId: info.chainId.toString(),
      height: info.height.toString(),
      mempoolSize: info.mempoolSize,
      latestHeader: encodeBlockHeader(info.latestHeader)
    };
  }
  async balance(addressHex) {
    let address;
    try {
      address = parseAddress(addressHex);
    } catch (err) {
      return { error: "invalid address", message: String(err) };
    }
    const { balance, nonce } = await this.options.node.getBalance(address);
    return {
      address: bytesToDisplayAddress(address),
      balance: balance.toString(),
      nonce: nonce.toString()
    };
  }
  async accountFull(addressHex) {
    let address;
    try {
      address = parseAddress(addressHex);
    } catch (err) {
      return { error: "invalid address", message: String(err) };
    }
    const account = await this.options.node.getAccountFull(address);
    if (!account) {
      return {
        address: bytesToDisplayAddress(address),
        exists: false,
        height: this.options.node.getChainInfo().height.toString()
      };
    }
    return {
      address: bytesToDisplayAddress(address),
      exists: true,
      balance: account.balance.toString(),
      nonce: account.nonce.toString(),
      codeHash: bytesToHex7(account.codeHash),
      storageRoot: bytesToHex7(account.storageRoot),
      height: account.height.toString()
    };
  }
  async block(numberStr) {
    let number;
    try {
      number = BigInt(numberStr);
    } catch {
      return { error: "invalid block number", value: numberStr };
    }
    const block = await this.options.node.getBlock(number);
    if (!block) return { error: "not found", number: numberStr };
    const receipts = await this.options.node.getReceiptsByBlock(number);
    return encodeBlock(block, receipts);
  }
  async blockReceipts(numberStr) {
    let number;
    try {
      number = BigInt(numberStr);
    } catch {
      return { error: "invalid block number", value: numberStr };
    }
    const receipts = await this.options.node.getReceiptsByBlock(number);
    return {
      blockNumber: number.toString(),
      receipts: receipts.map(encodeReceipt)
    };
  }
  async receiptByHash(txHashHex) {
    let txHash3;
    try {
      txHash3 = parseHex3(txHashHex, 32);
    } catch (err) {
      return { error: "invalid tx hash", message: String(err) };
    }
    const receipt = await this.options.node.getReceiptByTxHash(txHash3);
    if (!receipt) return { error: "receipt not found", txHash: bytesToHex7(txHash3) };
    return encodeReceipt(receipt);
  }
  async blockRaw(numberStr) {
    let number;
    try {
      number = BigInt(numberStr);
    } catch {
      return { error: "invalid block number", value: numberStr };
    }
    const block = await this.options.node.getBlock(number);
    if (!block) return { error: "not found", number: numberStr };
    const bytes = BlockSchema4.serialize(block);
    return {
      number: block.header.number.toString(),
      bytes: bytesToHex7(bytes)
    };
  }
  /**
   * Dispatch an Ethereum JSON-RPC request or batch to the eth handler.
   * Single requests return a JsonRpcResponse object; batch requests
   * return an array of JsonRpcResponse objects, preserving request
   * order. Malformed payloads get a parse-error response.
   */
  async handleJsonRpc(body) {
    if (Array.isArray(body)) {
      if (body.length === 0) {
        return {
          jsonrpc: "2.0",
          id: null,
          error: { code: -32600, message: "empty batch" }
        };
      }
      return this.ethRpc.handleBatch(body);
    }
    if (body && typeof body === "object") {
      return this.ethRpc.handle(body);
    }
    return {
      jsonrpc: "2.0",
      id: null,
      error: { code: -32700, message: "parse error" }
    };
  }
  consensusState() {
    try {
      return this.options.node.getConsensusState();
    } catch (err) {
      return { error: "failed to read consensus state", message: String(err) };
    }
  }
  async submitProposal(body) {
    const rawBlock = body.rawBlock;
    if (typeof rawBlock !== "string") {
      return { accepted: false, reason: "missing rawBlock (expected hex string)" };
    }
    let bytes;
    try {
      bytes = parseHex3(rawBlock);
    } catch (err) {
      return { accepted: false, reason: `invalid rawBlock hex: ${String(err)}` };
    }
    try {
      const block = BlockSchema4.deserialize(new Uint8Array(bytes));
      const inSet = await this.options.node.isInActiveValidatorSet(block.header.proposer);
      if (!inSet) {
        return { accepted: false, reason: "proposer not in active validator set" };
      }
    } catch (err) {
      return { accepted: false, reason: `invalid rawBlock SSZ: ${String(err)}` };
    }
    let proposerRound;
    if (typeof body.round === "string") {
      try {
        proposerRound = BigInt(body.round);
      } catch {
      }
    }
    try {
      const result = await this.options.node.applyProposalWithBft(bytes, { proposerRound });
      if (result.accepted) {
        return {
          accepted: true,
          appliedHeight: result.block.header.number.toString()
        };
      }
      return { accepted: false, reason: result.reason };
    } catch (err) {
      return { accepted: false, reason: `apply failed: ${String(err)}` };
    }
  }
  async submitVote(body) {
    const rawVote = body.rawVote;
    if (typeof rawVote !== "string") {
      return { accepted: false, reason: "missing rawVote (expected hex string)" };
    }
    let bytes;
    try {
      bytes = parseHex3(rawVote);
    } catch (err) {
      return { accepted: false, reason: `invalid rawVote hex: ${String(err)}` };
    }
    let vote;
    try {
      vote = SignedVoteSchema4.deserialize(bytes);
    } catch (err) {
      return { accepted: false, reason: `invalid rawVote SSZ: ${String(err)}` };
    }
    const inSet = await this.options.node.isInActiveValidatorSet(vote.body.validator);
    if (!inSet) {
      return { accepted: false, reason: "voter not in active validator set" };
    }
    const result = this.options.node.ingestConsensusVote(vote);
    if (!result.ok) {
      return { accepted: false, reason: result.reason };
    }
    return {
      accepted: true,
      kind: result.kind,
      duplicate: result.duplicate,
      prevoteCount: result.prevoteCount,
      precommitCount: result.precommitCount,
      quorumReached: result.quorumReached
    };
  }
  /**
   * POST /v3/precommit handler. Decodes the hex v3 VOTE wire frame (the SAME
   * codec the ANT VOTE path uses), then hands it to the node's authenticated
   * ingest — which verifies the sig unconditionally, rejects a non-committee
   * or unknown voter, and folds an accepted precommit into the GRANDPA cert.
   */
  async submitV3Precommit(body) {
    const wire = body.wire;
    if (typeof wire !== "string") {
      return { accepted: false, reason: "missing wire (expected hex string)" };
    }
    let bytes;
    try {
      bytes = parseHex3(wire);
    } catch (err) {
      return { accepted: false, reason: `invalid wire hex: ${String(err)}` };
    }
    let vote;
    try {
      const msg = decodeMessage(bytes);
      if (msg.type !== "vote") {
        return { accepted: false, reason: `expected vote frame, got ${msg.type}` };
      }
      vote = msg.vote;
    } catch (err) {
      return { accepted: false, reason: `invalid vote frame: ${String(err)}` };
    }
    const result = await this.options.node.ingestV3HttpPrecommit(vote);
    return {
      accepted: result.accepted,
      reason: result.reason,
      finalized: result.finalized
    };
  }
  async tombstones() {
    try {
      const list = await this.options.node.getTombstones();
      return {
        count: list.length,
        slashingContract: bytesToDisplayAddress(this.options.node.getSlashingContractAddress()),
        tombstones: list
      };
    } catch (err) {
      return { error: "failed to query tombstones", message: String(err) };
    }
  }
  async validators() {
    const now = Date.now();
    if (this.validatorsCache && now - this.validatorsCache.at < _RpcServer.VALIDATORS_CACHE_MS) {
      return this.validatorsCache.body;
    }
    try {
      const node = this.options.node;
      const list = await node.getAllValidators();
      const head = node.getChainInfo().height;
      const epochBlocks = node.epochBlocks;
      const epochEntryWindow = node.epochEntryWindow;
      const currentEpoch = head / epochBlocks;
      const currentEpochStart = currentEpoch * epochBlocks;
      const currentEpochEnds = currentEpochStart + epochBlocks;
      const currentWindowOpensAt = currentEpochEnds - epochEntryWindow;
      const nextWindowOpensAt = head < currentWindowOpensAt ? currentWindowOpensAt : currentEpochEnds + epochBlocks - epochEntryWindow;
      const liveness = node.getLivenessSnapshot();
      const missMap = /* @__PURE__ */ new Map();
      const lastVotedMap = /* @__PURE__ */ new Map();
      const bakedSet = new Set(liveness.baked.map((a) => a.toLowerCase()));
      for (const t of liveness.tracked) {
        missMap.set(t.address.toLowerCase(), t.consecutiveMisses);
        lastVotedMap.set(t.address.toLowerCase(), t.lastVotedHeight);
      }
      const missThreshold = liveness.maxConsecutiveMisses;
      let tombstonedSet = /* @__PURE__ */ new Set();
      try {
        const tombs = await node.getTombstones();
        tombstonedSet = new Set(tombs.map((t) => t.address.toLowerCase()));
      } catch {
      }
      const validators = list.map((v) => {
        const addrLc = (v.address || "").toLowerCase();
        const bondedStatus = v.status ?? "active";
        const misses = missMap.get(addrLc) ?? 0;
        const lastVoted = lastVotedMap.get(addrLc) ?? null;
        const baked = bakedSet.has(addrLc);
        let committeeState;
        let nextCommitteeEntryAt = null;
        if (tombstonedSet.has(addrLc)) {
          committeeState = "tombstoned";
        } else if (bondedStatus === "pending") {
          committeeState = "pending_bond";
          nextCommitteeEntryAt = nextWindowOpensAt.toString();
        } else if (baked) {
          committeeState = "signing";
        } else if (misses >= missThreshold && missThreshold > 0) {
          committeeState = "dormant";
          nextCommitteeEntryAt = nextWindowOpensAt.toString();
        } else if (lastVoted === null) {
          committeeState = "waiting_epoch_entry";
          nextCommitteeEntryAt = nextWindowOpensAt.toString();
        } else {
          committeeState = "signing";
        }
        return {
          address: hexAddressToDisplay(v.address),
          stake: v.stake,
          bondedAt: v.bondedAt,
          status: bondedStatus,
          activeAtBlock: v.activeAtBlock,
          pubKeySize: (v.pubKey || "").length,
          broadcastUrl: v.broadcastUrl ?? "",
          committeeState,
          consecutiveMisses: misses,
          lastVotedHeight: lastVoted,
          nextCommitteeEntryAt
        };
      });
      const body = {
        count: validators.length,
        stakingContract: bytesToDisplayAddress(node.getStakingContractAddress()),
        epoch: {
          head: head.toString(),
          current: currentEpoch.toString(),
          blocksPerEpoch: epochBlocks.toString(),
          entryWindowBlocks: epochEntryWindow.toString(),
          currentStart: currentEpochStart.toString(),
          currentEnds: currentEpochEnds.toString(),
          currentWindowOpensAt: currentWindowOpensAt.toString(),
          nextWindowOpensAt: nextWindowOpensAt.toString()
        },
        validators
      };
      this.validatorsCache = { at: now, body };
      return body;
    } catch (err) {
      return { error: "failed to query validators", message: String(err) };
    }
  }
  /** Read-only participation tracker. Scans the last `lookback` blocks
   * and counts how often each validator's address appears in the
   * decoded lastCommitBitmap — i.e. who's been actually signing vs
   * just bonded-and-silent. No state changes; safe to hit at any rate.
   *
   * The bitmap at block h was encoded against the per-epoch committee
   * computed deterministically by computeCommitteeForEpoch (not the
   * flat bonded set). Decoding against getAllValidators() (the previous
   * behaviour) attributed bits to the wrong addresses — every actually-
   * signing validator showed as 0 signedBlocks and random non-signers
   * got credit. Fixed by resolving the per-epoch committee for each
   * block we scan; results are cached per epoch to keep the scan cheap.
   *
   * getCommitteeForEpoch MUST return the DETERMINISTIC (pre-dormancy)
   * committee — the dormancy-filtered subset shifts every bit past a
   * dropped member onto the wrong address (prod 2026-08-04: dead
   * validators credited with live voters' signatures). */
  async participation(query) {
    try {
      const node = this.options.node;
      const head = node.getChainInfo().height;
      const lookbackParam = query.get("lookback");
      const requested = lookbackParam ? Number(lookbackParam) : 1e3;
      const lookback = Math.max(1, Math.min(1e4, isNaN(requested) ? 1e3 : requested));
      const startHeight = head - BigInt(lookback) + 1n;
      const actualStart = startHeight > 1n ? startHeight : 1n;
      const actualLookback = Number(head - actualStart + 1n);
      const validators = await node.getAllValidators();
      const stakeByAddr = new Map(
        validators.map((v) => [v.address.toLowerCase(), v.stake])
      );
      const EPOCH = node.epochBlocks;
      const committeeCache = /* @__PURE__ */ new Map();
      const resolveCommittee = async (epoch) => {
        const k = epoch.toString();
        const hit = committeeCache.get(k);
        if (hit) return hit;
        try {
          const c = await node.getCommitteeForEpoch(epoch);
          const addrs = c.map((v) => v.address.toLowerCase());
          committeeCache.set(k, addrs);
          return addrs;
        } catch {
          committeeCache.set(k, []);
          return [];
        }
      };
      const counts = /* @__PURE__ */ new Map();
      const ensure = (addr) => {
        let c = counts.get(addr);
        if (!c) {
          c = { signed: 0, last: null };
          counts.set(addr, c);
        }
        return c;
      };
      for (const v of validators) ensure(v.address.toLowerCase());
      for (let h = actualStart; h <= head; h += 1n) {
        const block = await node.getBlock(h);
        if (!block) continue;
        const prevHeight = h > 0n ? h - 1n : 0n;
        const committee = await resolveCommittee(prevHeight / EPOCH);
        if (committee.length === 0) continue;
        let signers;
        try {
          signers = decodeLastCommitBitmap(block.header.lastCommitBitmap, committee);
        } catch {
          continue;
        }
        for (const s of signers) {
          const c = ensure(s);
          c.signed += 1;
          c.last = h;
        }
      }
      const recentWindow = Math.min(100, actualLookback);
      const recentCutoff = head - BigInt(recentWindow);
      const rows = Array.from(counts.entries()).map(([addr, c]) => {
        let status;
        if (c.last !== null && c.last > recentCutoff) status = "signing";
        else if (c.signed > 0) status = "idle";
        else status = "dormant";
        return {
          address: addr,
          stake: stakeByAddr.get(addr) ?? "0",
          signedBlocks: c.signed,
          participationRatio: Number((c.signed / actualLookback).toFixed(4)),
          lastSignedBlock: c.last !== null ? c.last.toString() : null,
          status
        };
      }).sort((a, b) => b.signedBlocks - a.signedBlocks);
      const signingCount = rows.filter((r) => r.status === "signing").length;
      const idleCount = rows.filter((r) => r.status === "idle").length;
      const dormantCount = rows.filter((r) => r.status === "dormant").length;
      return {
        head: head.toString(),
        scannedFrom: actualStart.toString(),
        lookback: actualLookback,
        totalValidators: validators.length,
        signing: signingCount,
        idle: idleCount,
        dormant: dormantCount,
        recentWindow,
        validators: rows
      };
    } catch (err) {
      return { error: "failed to compute participation", message: String(err) };
    }
  }
  async governanceProposals() {
    try {
      const result = await this.viewCall({
        contract: "0x0000000000000000000000000000000000000003",
        method: "getProposals",
        args: []
      });
      if (result.ok === false) return { proposals: [], error: String(result.reason) };
      return { proposals: result.returnValue ?? [] };
    } catch (err) {
      console.error("[governance] proposals query error:", err);
      return { proposals: [], error: err instanceof Error ? err.message : String(err) };
    }
  }
  async governanceProposal(idStr) {
    try {
      const result = await this.viewCall({
        contract: "0x0000000000000000000000000000000000000003",
        method: "getProposal",
        args: [idStr]
      });
      if (!result.ok) return { proposal: null, error: result.reason };
      const amendResult = await this.viewCall({
        contract: "0x0000000000000000000000000000000000000003",
        method: "getAmendments",
        args: [idStr]
      });
      return {
        proposal: result.returnValue ?? null,
        amendments: amendResult.ok ? amendResult.returnValue ?? [] : []
      };
    } catch (err) {
      return { proposal: null, error: String(err) };
    }
  }
  async governanceLibraries() {
    try {
      const result = await this.viewCall({
        contract: "0x0000000000000000000000000000000000000003",
        method: "getApprovedLibraries",
        args: []
      });
      if (!result.ok) return { libraries: [], error: result.reason };
      return { libraries: result.returnValue ?? [] };
    } catch (err) {
      return { libraries: [], error: String(err) };
    }
  }
  async governanceLibrary(name) {
    try {
      const result = await this.viewCall({
        contract: "0x0000000000000000000000000000000000000003",
        method: "getApprovedLibrary",
        args: [name]
      });
      if (!result.ok) return { library: null, error: result.reason };
      return { library: result.returnValue ?? null };
    } catch (err) {
      return { library: null, error: String(err) };
    }
  }
  async governanceLocked(address) {
    try {
      const result = await this.viewCall({
        contract: "0x0000000000000000000000000000000000000003",
        method: "getLockedBalance",
        args: [address]
      });
      if (!result.ok) return { locked: "0", error: result.reason };
      return { address, locked: String(result.returnValue ?? "0") };
    } catch (err) {
      return { locked: "0", error: String(err) };
    }
  }
  libp2pAddrs() {
    if (!this.options.getLibp2pInfo) {
      return { enabled: false };
    }
    const info = this.options.getLibp2pInfo();
    return {
      enabled: true,
      peerId: info.peerId,
      addresses: info.addresses
    };
  }
  submitTx(body) {
    const rawTx = body.rawTx;
    if (typeof rawTx !== "string") {
      return { accepted: false, reason: "missing rawTx (expected hex string)" };
    }
    let bytes;
    try {
      bytes = parseHex3(rawTx);
    } catch (err) {
      return { accepted: false, reason: `invalid rawTx hex: ${String(err)}` };
    }
    let tx;
    try {
      tx = SignedTransactionSchema6.deserialize(bytes);
    } catch (err) {
      return { accepted: false, reason: `invalid rawTx SSZ: ${String(err)}` };
    }
    const result = this.options.node.submitTransaction(tx);
    return {
      accepted: result.accepted,
      reason: result.reason,
      txHash: result.txHash ? bytesToHex7(result.txHash) : void 0
    };
  }
  async viewCall(body) {
    const contractHex = body.contract;
    const method = body.method;
    const args = body.args;
    if (typeof contractHex !== "string" || typeof method !== "string") {
      return {
        ok: false,
        reason: 'missing or invalid "contract" (hex string) or "method" (string)'
      };
    }
    let contract;
    try {
      contract = parseAddress(contractHex);
    } catch (err) {
      return { ok: false, reason: `invalid contract address: ${String(err)}` };
    }
    const argArray = Array.isArray(args) ? args : [];
    const result = await this.options.node.viewCall(contract, method, argArray);
    if (!result.ok) return { ok: false, reason: result.reason };
    return {
      ok: true,
      returnValue: jsonSafe(result.returnValue),
      events: result.events.map((e) => ({ name: e.name, data: jsonSafe(e.data) })),
      gasUsed: result.gasUsed.toString()
    };
  }
  async faucet(body) {
    const toHex3 = body.to;
    const amountStr = body.amount;
    if (typeof toHex3 !== "string" || typeof amountStr !== "string") {
      return { accepted: false, reason: "missing to or amount (both should be strings)" };
    }
    let to;
    try {
      to = parseAddress(toHex3);
    } catch (err) {
      return { accepted: false, reason: `invalid 'to' address: ${String(err)}` };
    }
    let amount;
    try {
      amount = BigInt(amountStr);
    } catch {
      return { accepted: false, reason: `invalid 'amount' value: ${amountStr}` };
    }
    const MAX_DRIP_WEI = 100000n * 10n ** 18n;
    if (amount > MAX_DRIP_WEI) {
      return { accepted: false, reason: "amount exceeds per-drip cap (100K ASE)" };
    }
    const result = await this.options.node.faucetTransfer(to, amount);
    return {
      accepted: result.accepted,
      reason: result.reason,
      txHash: result.txHash ? bytesToHex7(result.txHash) : void 0
    };
  }
  /**
   * Task #257: reachability probe used by the Operator app to warn a
   * validator whose advertised broadcastUrl is not reachable from the
   * rest of the cluster (typical for NAT'd home users without port
   * forwarding). The operator calls this endpoint on a baked relay
   * and passes its own broadcastUrl. Relay tries to fetch `${url}/metadata`
   * with a short timeout and reports back.
   *
   * Only accepts http(s) URLs to guard against internal-network probes
   * from arbitrary callers. Response fields intentionally minimal so
   * the Operator UI can drive its banner without parsing much.
   */
  async probeReachable(query) {
    const target = query.get("url");
    if (!target) return { reachable: false, error: "missing url query param" };
    if (!/^https?:\/\/[^\s]+$/i.test(target)) {
      return { reachable: false, error: "url must be http(s)://host[:port]" };
    }
    const probeUrl = target.replace(/\/+$/, "") + "/metadata";
    const timeoutMs = 4e3;
    const started = Date.now();
    try {
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), timeoutMs);
      try {
        const r = await fetch(probeUrl, { signal: ctrl.signal });
        const latencyMs = Date.now() - started;
        if (!r.ok) return { reachable: false, latencyMs, error: `HTTP ${r.status}` };
        const body = await r.json();
        if (!body || typeof body.chainId !== "string") {
          return { reachable: false, latencyMs, error: "response is not a chain metadata payload" };
        }
        return { reachable: true, latencyMs, chainId: body.chainId };
      } finally {
        clearTimeout(t);
      }
    } catch (err) {
      const latencyMs = Date.now() - started;
      const msg = err instanceof Error ? err.message : String(err);
      return { reachable: false, latencyMs, error: msg };
    }
  }
  json(res, status, body) {
    this.applyCors(res);
    res.statusCode = status;
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify(body));
  }
  html(res, status, body) {
    this.applyCors(res);
    res.statusCode = status;
    res.setHeader("Content-Type", "text/html; charset=utf-8");
    res.setHeader("Cache-Control", "no-cache");
    res.end(body);
  }
  text(res, status, body) {
    this.applyCors(res);
    res.statusCode = status;
    res.setHeader("Content-Type", "text/plain; charset=utf-8");
    res.end(body);
  }
};
var V3_PRECOMMIT_MAX_BODY_BYTES = 64 * 1024;
var BodyTooLargeError = class extends Error {
  constructor() {
    super("request body too large");
    this.name = "BodyTooLargeError";
  }
};
async function readJsonBody(req, maxBytes) {
  const parsed = await readJsonBodyRaw(req, maxBytes);
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
    return {};
  }
  return parsed;
}
async function readJsonBodyRaw(req, maxBytes) {
  const chunks = [];
  let total = 0;
  for await (const chunk of req) {
    const buf = chunk;
    total += buf.length;
    if (maxBytes !== void 0 && total > maxBytes) {
      req.destroy();
      throw new BodyTooLargeError();
    }
    chunks.push(buf);
  }
  const raw = Buffer.concat(chunks).toString("utf8");
  if (!raw) return {};
  try {
    return JSON.parse(raw);
  } catch (err) {
    throw new Error(`invalid JSON body: ${String(err)}`);
  }
}

// src/bin/run.ts
import { bytesToBech32Address as bytesToBech32Address3 } from "@asentum/crypto";
async function main() {
  const dataDir = process.env.ASENTUM_DATA_DIR ?? join7(homedir(), ".asentum", "dev");
  const chainId = BigInt(process.env.ASENTUM_CHAIN_ID ?? "1337");
  const host = process.env.ASENTUM_RPC_HOST ?? "127.0.0.1";
  const port = Number.parseInt(process.env.ASENTUM_RPC_PORT ?? "8545", 10);
  const p2pPort = Number.parseInt(process.env.ASENTUM_P2P_PORT ?? "0", 10);
  const p2pBootstrap = (process.env.ASENTUM_P2P_PEERS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
  const antPort = Number.parseInt(process.env.ASENTUM_ANT_PORT ?? "0", 10);
  const antPeers = (process.env.ASENTUM_ANT_PEERS ?? "").split(",").map((s) => s.trim()).filter(Boolean).map((entry) => {
    const idx = entry.lastIndexOf(":");
    if (idx <= 0) throw new Error(`ASENTUM_ANT_PEERS: malformed entry "${entry}" (want host:port)`);
    return {
      host: entry.slice(0, idx),
      port: Number.parseInt(entry.slice(idx + 1), 10),
      label: entry
    };
  });
  const blockTimeMs = Number.parseInt(process.env.ASENTUM_BLOCK_TIME ?? "2000", 10);
  const role = process.env.ASENTUM_ROLE ?? "producer";
  const peerRpcList = (process.env.ASENTUM_PEER_RPC ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
  const peerRpcUrl = peerRpcList[0];
  const peerP2pAddr = process.env.ASENTUM_PEER_P2P;
  const rawPeers = (process.env.ASENTUM_PEERS ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
  const selfUrls = /* @__PURE__ */ new Set([
    `http://${host}:${port}`,
    `http://127.0.0.1:${port}`,
    `http://localhost:${port}`
  ]);
  const consensusPeers = rawPeers.filter((url) => {
    if (selfUrls.has(url)) {
      console.log(`[consensus] excluding self URL from peers: ${url}`);
      return false;
    }
    return true;
  });
  const voteTimeoutMs = Number.parseInt(process.env.ASENTUM_VOTE_TIMEOUT_MS ?? "8000", 10);
  const quorumGraceMs = Number.parseInt(process.env.ASENTUM_QUORUM_GRACE_MS ?? "0", 10);
  const generateValidatorKey = process.env.ASENTUM_GENERATE_VALIDATOR_KEY === "1";
  const devnet = process.env.ASENTUM_DEVNET === "1";
  const validatorIndex = process.env.ASENTUM_VALIDATOR_INDEX !== void 0 ? Number.parseInt(process.env.ASENTUM_VALIDATOR_INDEX, 10) : null;
  const joinPeerRpcUrl = process.env.ASENTUM_JOIN_PEER_RPC;
  const corsOrigin = process.env.ASENTUM_CORS_ORIGIN ?? "*";
  const publicRpcUrl = process.env.ASENTUM_PUBLIC_RPC_URL;
  const publicExplorerUrl = process.env.ASENTUM_PUBLIC_EXPLORER_URL;
  const chainName = process.env.ASENTUM_CHAIN_NAME;
  const faucetRateLimit = process.env.ASENTUM_FAUCET_RATE_LIMIT === "1" || process.env.ASENTUM_PUBLIC_TESTNET === "1" ? {
    capacity: Number.parseInt(process.env.ASENTUM_FAUCET_CAPACITY ?? "1", 10),
    refillPerSecond: Number.parseFloat(
      process.env.ASENTUM_FAUCET_REFILL_PER_SEC ?? String(1 / 60)
    )
  } : void 0;
  if (role !== "producer" && role !== "replica") {
    throw new Error(`invalid ASENTUM_ROLE: ${role} (expected 'producer' or 'replica')`);
  }
  console.log("---");
  console.log("AsentumChain node (dev mode)");
  console.log("---");
  console.log(`  role:         ${role}`);
  console.log(`  data dir:     ${dataDir}`);
  console.log(`  chain id:     ${chainId}`);
  console.log(`  rpc:          http://${host}:${port}`);
  console.log(`  block time:   ${blockTimeMs} ms`);
  if (peerRpcUrl) console.log(`  peer rpc:     ${peerRpcUrl}`);
  if (peerP2pAddr) console.log(`  peer p2p:     ${peerP2pAddr}`);
  if (joinPeerRpcUrl) console.log(`  join peer:    ${joinPeerRpcUrl}`);
  if (consensusPeers.length > 0) {
    console.log(`  consensus peers: ${consensusPeers.join(", ")}`);
  }
  console.log(`  vote timeout: ${voteTimeoutMs} ms`);
  console.log(`  quorum grace: ${quorumGraceMs} ms`);
  console.log(`  validator key: ${generateValidatorKey ? "per-node (validator.key)" : "faucet fallback"}`);
  console.log(`  cors origin:  ${corsOrigin}`);
  if (publicRpcUrl) console.log(`  public rpc:   ${publicRpcUrl}`);
  if (publicExplorerUrl) console.log(`  public expl:  ${publicExplorerUrl}`);
  if (chainName) console.log(`  chain name:   ${chainName}`);
  if (faucetRateLimit) {
    console.log(
      `  faucet limit: ${faucetRateLimit.capacity} tok/ip, refill ${faucetRateLimit.refillPerSecond}/s`
    );
  }
  console.log(`  cron:         ${process.env.ASENTUM_DISABLE_CRON === "1" ? "DISABLED (ASENTUM_DISABLE_CRON=1)" : "enabled"}`);
  console.log("");
  const node = new AsentumNode({
    dataDir,
    chainId,
    blockTimeMs,
    role,
    voteTimeoutMs,
    quorumGraceMs,
    generateValidatorKey,
    joinPeerRpcUrl,
    catchupPeerUrls: consensusPeers,
    devnet,
    validatorIndex: validatorIndex ?? void 0
  });
  const initResult = await node.init();
  console.log(
    initResult.genesisCreated ? "[chain] created fresh genesis at height 0" : `[chain] resumed existing chain at height ${initResult.height}`
  );
  console.log(`[faucet] address: ${bytesToBech32Address3(initResult.faucet.address)}`);
  console.log(`[faucet] NOT for real value \u2014 key is deterministic and public`);
  console.log(`[validator] address: ${bytesToBech32Address3(node.getValidatorAddress())} (${node.getValidatorKey().source})`);
  console.log("");
  await node.initV3();
  const v3Engaged = process.env.USE_V3_ENGINE === "1";
  const voteBroadcaster = new VoteBroadcaster({
    peers: consensusPeers,
    log: (msg) => console.log(msg)
  });
  const refreshConsensusPeers = () => {
    const chainPeers = node.getValidatorBroadcastUrls(selfUrls);
    const merged = new Set(chainPeers);
    for (const p of consensusPeers) merged.add(p);
    voteBroadcaster.setPeers([...merged]);
  };
  refreshConsensusPeers();
  console.log(
    `[consensus] gossiping votes + proposals to ${voteBroadcaster.peerCount()} peer(s) (chain-derived + env fallback)`
  );
  const antDynamic = process.env.ASENTUM_V3_DYNAMIC_COMMITTEE === "1" && antPort > 0;
  const antEnabled = antPeers.length > 0 || antDynamic;
  const disableHttpGossip = antEnabled || process.env.ASENTUM_DISABLE_HTTP_GOSSIP === "1";
  if (antEnabled) {
    console.log(`[consensus] ANT enabled \u2014 ${antPeers.length} static peer(s)${antDynamic ? " + dynamic committee discovery" : ""}, HTTP gossip + libp2p bypassed for consensus`);
  } else if (disableHttpGossip) {
    console.log("[consensus] HTTP vote/proposal broadcast DISABLED \u2014 libp2p is the only consensus transport");
  }
  if (!disableHttpGossip) {
    node.onVoteProduced(async (vote) => {
      try {
        await voteBroadcaster.broadcast(vote);
      } catch (err) {
        console.error("[consensus] vote broadcast failed:", err);
      }
    });
    node.onProposalBroadcast(async (block, round) => {
      try {
        await voteBroadcaster.broadcastProposal(block, round);
      } catch (err) {
        console.error("[consensus] proposal broadcast failed:", err);
      }
    });
  }
  if (process.env.ASENTUM_PULL_MODE !== "1") {
    node.onBlockProduced(() => refreshConsensusPeers());
  }
  if (process.env.ASENTUM_PULL_MODE === "1") {
    const relayFallback = v3Engaged ? peerRpcList.join(",") : consensusPeers.join(",");
    const relayUrls = (process.env.ASENTUM_PULL_RELAYS ?? relayFallback).split(",").map((s) => s.trim()).filter(Boolean);
    if (!v3Engaged) {
      const { ProposalPoller: ProposalPoller2 } = await Promise.resolve().then(() => (init_proposal_poller(), proposal_poller_exports));
      const { VotePoller: VotePoller2 } = await Promise.resolve().then(() => (init_vote_poller(), vote_poller_exports));
      if (relayUrls.length === 0) {
        console.error("[pull-mode] no relay URLs available \u2014 set ASENTUM_PULL_RELAYS or ASENTUM_PEERS");
      } else {
        const proposalPoller = new ProposalPoller2({ relayUrls, node });
        const votePoller = new VotePoller2({ relayUrls, node });
        void proposalPoller.start();
        void votePoller.start();
        voteBroadcaster.setPeers(relayUrls);
        console.log(`[pull-mode] vote broadcaster pinned to ${relayUrls.length} relay(s) \u2014 chain-discovery peer list bypassed`);
      }
    }
    if (relayUrls.length > 0) {
      const txForwardRelays = relayUrls.slice();
      node.onTransactionSubmitted(async (tx) => {
        const bytes = SignedTransactionSchema7.serialize(tx);
        const rawTxHex = bytesToHex7(bytes);
        for (const url of txForwardRelays) {
          try {
            const res = await fetch(`${url}/tx`, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ rawTx: rawTxHex })
            });
            if (res.ok) return;
            console.warn(`[pull-mode/tx-forward] ${url} rejected tx: ${res.status}`);
          } catch (err) {
            console.warn(`[pull-mode/tx-forward] ${url} unreachable: ${err.message}`);
          }
        }
        console.error(`[pull-mode/tx-forward] all ${txForwardRelays.length} relays failed \u2014 tx stuck in local mempool`);
      });
      console.log(`[pull-mode/tx-forward] forwarding locally-submitted txs to ${txForwardRelays.length} relay(s) with fallback`);
    }
    setInterval(() => {
      void node.maybeSubmitEpochEntry(node.getChainInfo().height).catch((err) => {
        console.error("[epoch-entry] watchdog tick failed:", err);
      });
    }, 3e4).unref();
  }
  console.log("");
  if (role === "replica" && peerRpcUrl) {
    console.log(`[sync] catching up from ${peerRpcUrl}...`);
    try {
      const result = await initialSync({
        node,
        peerRpcUrl,
        log: (msg) => console.log(`[sync] ${msg}`)
      });
      console.log(
        `[sync] done: ${result.heightBefore} \u2192 ${result.heightAfter} (${result.blocksApplied} blocks)`
      );
    } catch (err) {
      console.error("[sync] initial sync failed:", err);
      process.exit(1);
    }
    console.log("");
  }
  const runDir = dirname4(fileURLToPath(import.meta.url));
  const workerUrl = pathToFileURL(join7(runDir, "..", "net", "libp2p-worker.js"));
  const disableLibp2p = antEnabled || process.env.ASENTUM_DISABLE_LIBP2P === "1";
  if (disableLibp2p) {
    console.log("[net] libp2p DISABLED");
  }
  const network = new NetworkLayer({
    chainId: Number(chainId),
    listenPort: p2pPort,
    workerUrl,
    onBlockReceived: async (bytes) => {
      try {
        await node.applyExternalBlock(bytes);
      } catch (err) {
        console.error("[net] dropped gossiped block:", err);
      }
    },
    onTransactionReceived: (bytes) => {
      const result = node.submitExternalTransaction(bytes);
      if (!result.accepted) {
        console.error("[net] dropped gossiped tx:", result.reason);
      }
    },
    onVoteReceived: async (bytes) => {
      try {
        const vote = SignedVoteSchema6.deserialize(bytes);
        const inSet = await node.isInActiveValidatorSet(vote.body.validator);
        if (!inSet) return;
        const r = node.ingestConsensusVote(vote);
        if (r.ok && !r.duplicate) {
          console.log(`[gossip-vote] accepted kind=${r.kind} from=${Array.from(vote.body.validator).slice(0, 4).map((b) => b.toString(16).padStart(2, "0")).join("")}\u2026`);
        }
      } catch (err) {
        console.error("[net] dropped gossiped vote:", err);
      }
    },
    onProposalReceived: async (bytes) => {
      if (bytes.length < 16) {
        console.error("[net] dropped malformed proposal (too short)");
        return;
      }
      const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
      const proposerRound = view.getBigUint64(0, true);
      const polRoundRaw = view.getBigUint64(8, true);
      const POL_NONE = 0xFFFFFFFFFFFFFFFFn;
      const polRound = polRoundRaw === POL_NONE ? null : polRoundRaw;
      const blockBytes = bytes.slice(16);
      try {
        const block = BlockSchema5.deserialize(blockBytes);
        const inSet = await node.isInActiveValidatorSet(block.header.proposer);
        if (!inSet) {
          console.log(`[net] dropped proposal: proposer not in active set`);
          return;
        }
        const result = await node.applyProposalWithBft(blockBytes, { proposerRound, polRound });
        if (result.accepted) {
          const polStr = polRound === null ? "" : ` polRound=${polRound}`;
          console.log(`[gossip-proposal] accepted h=${block.header.number} r=${proposerRound}${polStr}`);
        } else {
          console.log(`[gossip-proposal] not accepted: ${result.reason}`);
        }
      } catch (err) {
        console.error("[net] dropped gossiped proposal:", err);
      }
    }
  });
  if (!disableLibp2p) {
    await network.start();
    console.log(`[p2p] peer id: ${network.getPeerId()}`);
    for (const addr of network.getMultiaddrs()) {
      console.log(`[p2p] listening on ${addr}`);
    }
    console.log("");
  }
  let antSwarm = null;
  if (antEnabled) {
    if (antPort === 0) {
      throw new Error("ASENTUM_ANT_PEERS set but ASENTUM_ANT_PORT is missing");
    }
    antSwarm = new AntSwarm({
      listenPort: antPort,
      listenHost: "0.0.0.0",
      dialTargets: antPeers,
      log: (msg) => console.log(msg),
      onPeerChange: (label, connected, role2) => {
        console.log(`[ant] peer ${connected ? "connected" : "disconnected"} (${role2}): ${label}`);
      },
      onMessage: {
        [MsgType.VOTE]: async (frame) => {
          try {
            const vote = SignedVoteSchema6.deserialize(frame.payload);
            const inSet = await node.isInActiveValidatorSet(vote.body.validator);
            if (!inSet) return;
            const r = node.ingestConsensusVote(vote);
            if (r.ok && !r.duplicate) {
              console.log(`[ant-vote] accepted kind=${r.kind} from=${Array.from(vote.body.validator).slice(0, 4).map((b) => b.toString(16).padStart(2, "0")).join("")}\u2026`);
            }
          } catch (err) {
            console.error("[ant] dropped vote:", err);
          }
        },
        [MsgType.PROPOSAL]: async (frame) => {
          const bytes = frame.payload;
          if (bytes.length < 16) {
            console.error("[ant] dropped malformed proposal (too short)");
            return;
          }
          const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
          const proposerRound = view.getBigUint64(0, true);
          const polRoundRaw = view.getBigUint64(8, true);
          const POL_NONE = 0xFFFFFFFFFFFFFFFFn;
          const polRound = polRoundRaw === POL_NONE ? null : polRoundRaw;
          const blockBytes = bytes.slice(16);
          try {
            const block = BlockSchema5.deserialize(blockBytes);
            const inSet = await node.isInActiveValidatorSet(block.header.proposer);
            if (!inSet) {
              console.log(`[ant] dropped proposal: proposer not in active set`);
              return;
            }
            const result = await node.applyProposalWithBft(blockBytes, { proposerRound, polRound });
            if (result.accepted) {
              const polStr = polRound === null ? "" : ` polRound=${polRound}`;
              console.log(`[ant-proposal] accepted h=${block.header.number} r=${proposerRound}${polStr}`);
            } else {
              console.log(`[ant-proposal] not accepted: ${result.reason}`);
            }
          } catch (err) {
            console.error("[ant] dropped proposal:", err);
          }
        },
        [MsgType.BLOCK]: async (frame) => {
          try {
            await node.applyExternalBlock(frame.payload);
          } catch (err) {
            console.error("[ant] dropped block:", err);
          }
        },
        [MsgType.TRANSACTION]: (frame) => {
          const result = node.submitExternalTransaction(frame.payload);
          if (!result.accepted) console.error("[ant] dropped tx:", result.reason);
        }
      }
    });
    await antSwarm.start();
    const v3Engine = node.getV3Engine();
    if (v3Engine) {
      node.setV3BroadcastCallbacks({
        block: (bytes) => antSwarm.broadcast(MsgType.BLOCK, bytes),
        vote: (bytes) => antSwarm.broadcast(MsgType.VOTE, bytes),
        // Block-sync (task #410): a behind node broadcasts SYNC_REQUEST;
        // peers answer by re-sending the requested blocks as BLOCK frames.
        syncRequest: (bytes) => antSwarm.broadcast(MsgType.SYNC_REQUEST, bytes)
      });
      const staticAntHosts = new Set(antPeers.map((p) => p.host));
      const syncCommitteeMesh = async () => {
        try {
          const { hosts, targets } = await node.getV3CommitteeAntTargets();
          if (hosts.size === 0) return;
          antSwarm.setAllowedInboundHosts(/* @__PURE__ */ new Set([...staticAntHosts, ...hosts]));
          const want = new Set(targets.map((t) => t.label));
          for (const label of antSwarm.dialTargetLabels()) {
            if (label.startsWith("committee:") && !want.has(label)) {
              antSwarm.removeDialTarget(label);
            }
          }
          for (const t of targets) antSwarm.addDialTarget(t);
        } catch (err) {
          console.error("[ant] committee mesh sync failed:", err.message);
        }
      };
      void syncCommitteeMesh();
      const committeeSyncMs = Number(process.env.ASENTUM_ANT_COMMITTEE_SYNC_MS ?? "25000") || 25e3;
      setInterval(() => void syncCommitteeMesh(), committeeSyncMs);
      const { decodeMessage: v3Decode, encodeBlockWithBodyMessage: v3EncodeBlock } = await Promise.resolve().then(() => (init_v3(), v3_exports));
      const originalBlock = antSwarm["opts"].onMessage[MsgType.BLOCK];
      const originalVote = antSwarm["opts"].onMessage[MsgType.VOTE];
      const testBlockDropPct = Number(process.env.ASENTUM_TEST_DROP_ANT_BLOCK_PCT ?? "0");
      antSwarm["opts"].onMessage[MsgType.BLOCK] = (frame, _from) => {
        if (Number.isFinite(testBlockDropPct) && testBlockDropPct > 0 && Math.random() * 100 < testBlockDropPct) {
          return;
        }
        try {
          const msg = v3Decode(frame.payload);
          if (msg.type === "block-with-body") {
            void v3Engine.onPeerBlock({ header: msg.header, bodyBytes: msg.bodyBytes });
            return;
          }
          if (msg.type === "block") {
            void v3Engine.onPeerBlock({ header: msg.block.header, bodyBytes: new Uint8Array(0) });
            return;
          }
        } catch {
        }
      };
      antSwarm["opts"].onMessage[MsgType.VOTE] = (frame, _from) => {
        try {
          const msg = v3Decode(frame.payload);
          if (msg.type === "vote") {
            if (!node.verifyPeerVoteV3(msg.vote)) return;
            v3Engine.onPeerVote(msg.vote);
            return;
          }
        } catch {
        }
      };
      antSwarm["opts"].onMessage[MsgType.SYNC_REQUEST] = (frame, _from) => {
        try {
          if (frame.payload.length < 16) return;
          const dv = new DataView(
            frame.payload.buffer,
            frame.payload.byteOffset,
            frame.payload.byteLength
          );
          const from = dv.getBigUint64(0, false);
          const to = dv.getBigUint64(8, false);
          const cappedTo = to - from > 512n ? from + 512n : to;
          const blocks = v3Engine.serveBlocks(from, cappedTo);
          for (const b of blocks) {
            antSwarm.broadcast(MsgType.BLOCK, v3EncodeBlock(b.header, b.bodyBytes));
          }
        } catch {
        }
      };
      void originalBlock;
      void originalVote;
    }
  }
  node.onBlockProduced(async (block) => {
    const bytes = BlockSchema5.serialize(block);
    if (antSwarm) {
      antSwarm.broadcast(MsgType.BLOCK, bytes);
    } else {
      try {
        await network.publishBlock(bytes);
      } catch (err) {
        console.error("[net] publishBlock failed:", err);
      }
    }
  });
  node.onTransactionSubmitted(async (tx) => {
    const bytes = SignedTransactionSchema7.serialize(tx);
    if (antSwarm) {
      antSwarm.broadcast(MsgType.TRANSACTION, bytes);
    } else {
      try {
        await network.publishTransaction(bytes);
      } catch (err) {
        console.error("[net] publishTransaction failed:", err);
      }
    }
  });
  node.onVoteProduced(async (vote) => {
    const bytes = SignedVoteSchema6.serialize(vote);
    if (antSwarm) {
      antSwarm.broadcast(MsgType.VOTE, bytes);
    } else {
      try {
        await network.publishVote(bytes);
      } catch (err) {
        console.error("[net] publishVote failed:", err);
      }
    }
  });
  node.onProposalBroadcast(async (block, round, polRound) => {
    const blockBytes = BlockSchema5.serialize(block);
    const out = new Uint8Array(16 + blockBytes.length);
    const view = new DataView(out.buffer);
    view.setBigUint64(0, round, true);
    const POL_NONE = 0xFFFFFFFFFFFFFFFFn;
    view.setBigUint64(8, polRound === null ? POL_NONE : polRound, true);
    out.set(blockBytes, 16);
    if (antSwarm) {
      antSwarm.broadcast(MsgType.PROPOSAL, out);
    } else {
      try {
        await network.publishProposal(out);
      } catch (err) {
        console.error("[net] publishProposal failed:", err);
      }
    }
  });
  const POISONED_HEIGHT_RAW = process.env.ASENTUM_INJECT_POISONED_VOTE_HEIGHT;
  if (POISONED_HEIGHT_RAW && antSwarm) {
    let injected = false;
    node.onBlockProduced(async (block) => {
      if (injected) return;
      if (block.header.number < 5n) return;
      injected = true;
      const { buildAndSignVote: buildAndSignVote2 } = await Promise.resolve().then(() => (init_sign(), sign_exports));
      const key6 = node.getValidatorKey();
      const poisoned = buildAndSignVote2({
        kind: "prevote",
        chainId,
        height: BigInt(POISONED_HEIGHT_RAW),
        round: 0n,
        blockHash: new Uint8Array(32),
        keypair: key6.keypair
      });
      const bytes = SignedVoteSchema6.serialize(poisoned);
      antSwarm.broadcast(MsgType.VOTE, bytes);
      console.log(
        `[poison-inject] broadcast prevote height=${POISONED_HEIGHT_RAW} round=0 (local h=${block.header.number}) \u2014 reproducing maxSeenVoteHeight poisoning`
      );
    });
  }
  if (peerP2pAddr) {
    try {
      await network.connectToPeer(peerP2pAddr);
      console.log(`[p2p] connected to ${peerP2pAddr}`);
    } catch (err) {
      console.error("[p2p] dial failed:", err);
    }
  }
  for (const ma of p2pBootstrap) {
    try {
      await network.connectToPeer(ma);
      console.log(`[p2p] connected to bootstrap ${ma}`);
    } catch (err) {
      console.error(`[p2p] bootstrap dial failed (${ma}):`, err);
    }
  }
  node.start();
  if (v3Engaged) {
    console.log("[v3] consensus driven by v3 engine (Aura+GRANDPA) / pull-mode");
  } else if (role === "producer") {
    console.log("[producer] block production timer running");
  } else {
    console.log("[replica] awaiting blocks via HTTP sync loop");
  }
  let httpSyncLoop;
  if (peerRpcUrl && !v3Engaged) {
    httpSyncLoop = new HttpSyncLoop({
      node,
      peerRpcUrl,
      intervalMs: Math.max(1e3, Math.floor(blockTimeMs / 2)),
      log: (msg) => console.log(msg)
    });
    httpSyncLoop.start();
    console.log(`[sync-loop] polling ${peerRpcUrl} every ${Math.max(1e3, Math.floor(blockTimeMs / 2))}ms`);
    node.onTransactionSubmitted(async (tx) => {
      try {
        const bytes = SignedTransactionSchema7.serialize(tx);
        const rawTxHex = bytesToHex7(bytes);
        const res = await fetch(`${peerRpcUrl}/tx`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ rawTx: rawTxHex })
        });
        if (!res.ok) {
          console.error(`[tx-forward] peer rejected tx: ${res.status}`);
        }
      } catch (err) {
        console.error("[tx-forward] failed to reach peer:", err);
      }
    });
    console.log(`[tx-forward] forwarding new txs to ${peerRpcUrl}/tx`);
  }
  const rpc = new RpcServer({
    node,
    port,
    host,
    corsOrigin,
    publicRpcUrl,
    publicExplorerUrl,
    chainName,
    faucetRateLimit,
    getLibp2pInfo: () => ({
      peerId: network.getPeerId(),
      addresses: network.getMultiaddrs()
    })
  });
  const { port: actualPort } = await rpc.start();
  console.log(`[rpc] listening on http://${host}:${actualPort}`);
  console.log(`[rpc] browser explorer: http://${host}:${actualPort}/`);
  console.log("");
  console.log("Press Ctrl+C to stop.");
  console.log("");
  let lastHeight = initResult.height;
  const blockLogger = setInterval(() => {
    const info = node.getChainInfo();
    if (info.height !== lastHeight) {
      const shortRoot = bytesToHex7(info.latestHeader.stateRoot).slice(0, 18);
      console.log(
        `[block #${info.height}] state ${shortRoot}... (gas ${info.latestHeader.gasUsed}, mempool ${info.mempoolSize})`
      );
      lastHeight = info.height;
    }
  }, 500);
  const shutdown = async (signal) => {
    console.log(`
Received ${signal}, shutting down...`);
    clearInterval(blockLogger);
    httpSyncLoop?.stop();
    try {
      await rpc.stop();
    } catch (err) {
      console.error("[rpc] stop failed:", err);
    }
    try {
      await network.stop();
    } catch (err) {
      console.error("[p2p] stop failed:", err);
    }
    try {
      await node.stop();
    } catch (err) {
      console.error("[node] stop failed:", err);
    }
    console.log("Goodbye.");
    process.exit(0);
  };
  process.on("SIGINT", () => {
    void shutdown("SIGINT");
  });
  process.on("SIGTERM", () => {
    void shutdown("SIGTERM");
  });
}
main().catch((err) => {
  const name = err && err.name || "Error";
  const message = err && err.message || String(err);
  const stack = err && err.stack || "(no stack available)";
  console.error(`fatal: ${name}: ${message}`);
  console.error(stack);
  process.exit(1);
});
//# sourceMappingURL=run.js.map