// src/bin/run.ts
import { homedir } from "os";
import { join as join4 } from "path";
import { BlockSchema as BlockSchema5, SignedTransactionSchema as SignedTransactionSchema7 } 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})`);
    }
    for (let h = localHeight + 1n; h <= peerHeight; h++) {
      const blockRes = await fetch(`${this.options.peerRpcUrl}/block-raw/${h.toString()}`);
      if (!blockRes.ok) {
        throw new Error(`peer /block-raw/${h}: ${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 = parseHex(body.bytes);
      await this.options.node.applyExternalBlock(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 { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { floodsub } from "@libp2p/floodsub";
import { identify } from "@libp2p/identify";
import { ping } from "@libp2p/ping";
import { tcp } from "@libp2p/tcp";
import { multiaddr } from "@multiformats/multiaddr";
import { createLibp2p } from "libp2p";
var BLOCKS_TOPIC = "/asentum/1/blocks/1.0.0";
var TXS_TOPIC = "/asentum/1/txs/1.0.0";
var NetworkLayer = class {
  libp2p;
  options;
  constructor(options) {
    this.options = options;
  }
  async start() {
    const listenPort = this.options.listenPort ?? 0;
    this.libp2p = await createLibp2p({
      addresses: {
        listen: [`/ip4/127.0.0.1/tcp/${listenPort}`]
      },
      transports: [tcp()],
      connectionEncrypters: [noise()],
      streamMuxers: [yamux()],
      connectionManager: {
        // Don't close connections for being idle — keep peer links alive
        // for as long as the nodes are running. At our scale (5–10 validators)
        // we can afford to maintain long-lived connections to all peers.
        inboundConnectionThreshold: 1e3,
        maxConnections: 1e3
      },
      services: {
        identify: identify(),
        // Ping keeps connections warm so the connection manager doesn't
        // prune them as idle.
        ping: ping(),
        // Floodsub: simple flood-based pubsub. Every message goes to every
        // peer subscribed to the topic. No mesh to negotiate, no subscription
        // propagation delay. Scales fine to the testnet target of 5–10
        // validators. Scales to the testnet target of 5-10 validators.
        pubsub: floodsub()
      }
    });
    await this.libp2p.start();
    this.libp2p.addEventListener("peer:connect", (evt) => {
      console.log("[p2p] peer:connect", evt.detail.toString());
    });
    this.libp2p.addEventListener("peer:disconnect", (evt) => {
      console.log("[p2p] peer:disconnect", evt.detail.toString());
    });
    const pubsub = this.getPubSub();
    pubsub.subscribe(BLOCKS_TOPIC);
    pubsub.subscribe(TXS_TOPIC);
    pubsub.addEventListener("message", (evt) => {
      const { topic, data } = evt.detail;
      if (topic === BLOCKS_TOPIC) {
        void Promise.resolve(this.options.onBlockReceived(data)).catch((err) => {
          console.error("[net] onBlockReceived failed:", err);
        });
      } else if (topic === TXS_TOPIC) {
        void Promise.resolve(this.options.onTransactionReceived(data)).catch((err) => {
          console.error("[net] onTransactionReceived failed:", err);
        });
      }
    });
  }
  async stop() {
    if (this.libp2p) {
      await this.libp2p.stop();
      this.libp2p = void 0;
    }
  }
  async connectToPeer(multiaddrStr) {
    if (!this.libp2p) throw new Error("network layer not started");
    const ma = multiaddr(multiaddrStr);
    await this.libp2p.dial(ma);
  }
  async publishBlock(bytes) {
    if (!this.libp2p) return;
    await this.getPubSub().publish(BLOCKS_TOPIC, bytes);
  }
  async publishTransaction(bytes) {
    if (!this.libp2p) return;
    await this.getPubSub().publish(TXS_TOPIC, bytes);
  }
  getMultiaddrs() {
    if (!this.libp2p) return [];
    return this.libp2p.getMultiaddrs().map((m) => m.toString());
  }
  getPeerId() {
    if (!this.libp2p) return "";
    return this.libp2p.peerId.toString();
  }
  /** Debug: list connected peers by peer-id string. */
  getConnectedPeers() {
    if (!this.libp2p) return [];
    return this.libp2p.getPeers().map((p) => p.toString());
  }
  /** Debug: list peer-ids known by the pubsub layer to be subscribed to a topic. */
  getPubSubPeers(topic) {
    if (!this.libp2p) return [];
    const pubsub = this.libp2p.services.pubsub;
    const subs = pubsub.getSubscribers?.(topic) ?? [];
    return subs.map((p) => p.toString());
  }
  getPubSub() {
    if (!this.libp2p) throw new Error("network layer not started");
    return this.libp2p.services.pubsub;
  }
};

// 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
import { BlockSchema } from "@asentum/types";

// src/consensus/sign.ts
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 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 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;
}

// src/net/vote-broadcast.ts
var VoteBroadcaster = class {
  peers;
  log;
  timeoutMs;
  hardCapMs;
  constructor(opts) {
    this.peers = [...opts.peers];
    this.log = opts.log ?? ((m) => console.error(m));
    this.timeoutMs = opts.timeoutMs ?? 1e4;
    this.hardCapMs = opts.hardCapMs ?? 12e3;
  }
  /**
   * 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 targets = this.peers.filter((p) => !exclude.has(p));
    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 rawBlockHex = bytesToHex(BlockSchema.serialize(block));
    const roundStr = round.toString();
    const sends = this.peers.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) {
    try {
      const ac = new AbortController();
      const timer = setTimeout(() => ac.abort(), this.timeoutMs);
      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}`);
        }
      } finally {
        clearTimeout(timer);
      }
    } catch (err) {
      this.log(`${logTag} peer ${peer} unreachable: ${String(err)}`);
    }
  }
  /**
   * @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/node/node.ts
import { mkdir as mkdir3 } from "fs/promises";
import { join as join3 } from "path";
import {
  blake3 as blake314
} from "@asentum/crypto";
import { StateStore } from "@asentum/state";
import {
  BlockHeaderSchema as BlockHeaderSchema3,
  BlockSchema as BlockSchema3,
  SignedTransactionSchema as SignedTransactionSchema4,
  SignedVoteSchema as SignedVoteSchema3
} from "@asentum/types";
import { Vm } from "@asentum/vm";

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

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

// src/chain/slashing-evidence.ts
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 blake32 } from "@asentum/crypto";
import "@asentum/types";
var STAKING_CONTRACT_ADDRESS = (() => {
  const addr = new Uint8Array(20);
  addr[19] = 1;
  return addr;
})();
var SLASHING_CONTRACT_ADDRESS = (() => {
  const addr = new Uint8Array(20);
  addr[19] = 2;
  return addr;
})();
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 = 50000n * (10n ** 18n);          // 50,000 ASE
const MAX_SELF_BOND_WEI = 50000000n * (10n ** 18n);       // 50,000,000 ASE
// 14-day unbonding window at 2-second blocks = 604,800 blocks.
const UNBONDING_BLOCKS = 604800n;

// \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;

({
  init(args) {
    const initial = (args && args.initialValidators) || [];
    const addresses = [];
    for (const v of initial) {
      const addr = String(v.address).toLowerCase();
      addresses.push(addr);
      storage.set(VALIDATOR_PREFIX + addr, JSON.stringify({
        address: addr,
        pubKey: String(v.pubKey),
        stake: String(v.stake),
        bondedAt: String(chain.blockNumber),
      }));
    }
    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.
  bond(pubKey) {
    const sender = String(msg.sender).toLowerCase();
    const amt = BigInt(msg.value);
    assert(amt > 0n, 'bond requires non-zero msg.value');

    const existingRaw = storage.get(VALIDATOR_PREFIX + sender);
    let validator;
    let newTotal;
    if (existingRaw) {
      validator = JSON.parse(existingRaw);
      newTotal = BigInt(validator.stake) + amt;
    } else {
      validator = {
        address: sender,
        pubKey: String(pubKey),
        stake: '0',
        bondedAt: String(chain.blockNumber),
      };
      newTotal = amt;
    }

    assert(newTotal >= MIN_SELF_BOND_WEI, 'bond below MIN_SELF_BOND (50,000 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 });
    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');

    // 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) {
  const sourceBytes = new TextEncoder().encode(STAKING_CONTRACT_SOURCE);
  const codeHash = blake32(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(key) {
      return current.get(key);
    },
    set(key, value) {
      current.set(key, value);
    },
    delete(key) {
      return current.delete(key);
    },
    has(key) {
      return current.has(key);
    }
  };
  vm.deploy(
    {
      source: STAKING_CONTRACT_SOURCE,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex2(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)
        }))
      }
    ]
  );
  for (const [key, value] of current) {
    if (snapshot.get(key) !== value) {
      await store.setStorageValue(STAKING_CONTRACT_ADDRESS, key, 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 = blake32(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(key) {
      return current.get(key);
    },
    set(key, value) {
      current.set(key, value);
    },
    delete(key) {
      return current.delete(key);
    },
    has(key) {
      return current.has(key);
    }
  };
  vm.deploy(
    {
      source: SLASHING_CONTRACT_SOURCE,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex2(SLASHING_CONTRACT_ADDRESS),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: storageAdapter,
      gasLimit: 50000000n
    },
    [{}]
  );
  for (const [key, value] of current) {
    await store.setStorageValue(SLASHING_CONTRACT_ADDRESS, key, 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 = blake32(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(key) {
      return current.get(key);
    },
    set(key, value) {
      current.set(key, value);
    },
    delete(key) {
      return current.delete(key);
    },
    has(key) {
      return current.has(key);
    }
  };
  vm.deploy(
    {
      source: GOVERNANCE_CONTRACT_SOURCE,
      context: {
        sender: "0x" + "00".repeat(20),
        contractAddress: bytesToHex2(GOVERNANCE_CONTRACT_ADDRESS),
        value: 0n,
        blockNumber: 0n,
        blockTimestamp: 0n,
        chainId
      },
      storage: storageAdapter,
      gasLimit: 50000000n
    },
    [{}]
  );
  for (const [key, value] of current) {
    await store.setStorageValue(GOVERNANCE_CONTRACT_ADDRESS, key, 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 bytesToHex2(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 blake33(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 = blake33(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(bytesToHex3(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(bytesToHex3(contractAddress).toLowerCase(), source);
  contractStorageCache.set(bytesToHex3(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(bytesToHex3(contractAddress).toLowerCase(), freshStorageAdapter);
    allStorageAdapters.push({ address: contractAddress, adapter: freshStorageAdapter });
    callStack.push(bytesToHex3(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 = 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: bytesToHex3(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 updatedSender = {
    balance: sender.balance - totalDebit,
    nonce: sender.nonce + 1n,
    codeHash: sender.codeHash,
    storageRoot: sender.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, value } of initialEntries) {
    initial.set(key, value);
  }
  const current = new Map(initial);
  return {
    get(key) {
      return current.get(key);
    },
    set(key, value) {
      current.set(key, value);
    },
    delete(key) {
      return current.delete(key);
    },
    has(key) {
      return current.has(key);
    },
    async flush() {
      for (const [key, value] of current) {
        if (initial.get(key) !== value) {
          await store.setStorageValue(contractAddress, key, value);
        }
      }
      for (const key of initial.keys()) {
        if (!current.has(key)) {
          await store.deleteStorageValue(contractAddress, key);
        }
      }
    }
  };
}
function buildVmContext(sender, contractAddress, tx, exec) {
  return {
    sender: bytesToHex3(sender),
    contractAddress: bytesToHex3(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 bytesToHex3(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, value } of entries) snapshot.set(key, 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 ? bytesToHex3(sender) : "0x" + "00".repeat(20);
  try {
    const result = vm.call(
      {
        source,
        context: {
          sender: senderHex,
          contractAddress: bytesToHex3(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, value } of entries) snapshot.set(key, value);
  const readOnlyStorage = {
    get(k) {
      return snapshot.get(k);
    },
    set(k, v) {
      snapshot.set(k, v);
    },
    delete(k) {
      return snapshot.delete(k);
    },
    has(k) {
      return snapshot.has(k);
    }
  };
  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: bytesToHex3(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 blake34(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 blake36 } from "@asentum/crypto";
import {
  BlockHeaderSchema,
  SignedTransactionSchema,
  ZERO_HASH as ZERO_HASH2
} from "@asentum/types";

// src/chain/receipts.ts
import { blake3 as blake35 } 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 blake35(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 = blake36(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 = blake36(BlockHeaderSchema.serialize(parentHeader));
  const header = {
    parentHash,
    stateRoot,
    transactionsRoot,
    receiptsRoot,
    number: blockNumber,
    timestamp,
    gasLimit,
    gasUsed,
    baseFeePerGas,
    proposer,
    lastCommitBitmap: options.lastCommitBitmap
  };
  return { block: { header, transactions: applied }, 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 blake36(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 blake37 } 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)
      ),
      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 path = join(
      dir,
      `block-${input.block.header.number}-${Date.now()}.json`
    );
    await writeFile(path, JSON.stringify(dump, null, 2));
    return path;
  } 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 = blake37(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
} from "@asentum/types";

// src/chain/cron-registry.ts
import { blake3 as blake38 } from "@asentum/crypto";
import "@asentum/types";
function bytesToHex4(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.timestamp), '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 = blake38(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: bytesToHex4(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/ase-token.ts
import { blake3 as blake39 } from "@asentum/crypto";
import "@asentum/types";
function bytesToHex5(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 = blake39(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: bytesToHex5(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 ?? []
    });
  }
  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)
  };
  return { header, transactions: [] };
}

// src/node/genesis-validators.ts
var ONE_ASE = 10n ** 18n;
var BAKED_STAKE = 25000000n * ONE_ASE;
var BAKED_GENESIS_VALIDATORS = [
  {
    address: "0x954643cd90faeb2adf46e493ddb46fb00f96e084",
    pubKey: "0x2900fb9736d85fc73d51d7032f88c8746712015f707cc5e75b004ff590dad9f33610e51d4213b16902a53705d216b6065c5d2ec46bf31c761dcdd01c8af716b8456072b85358f2104fc694ce72f6a7bc3426606daf0c41d00ce24672f390908c8c31c62cf0d9a3b4961ee4b9ddefe91d2a0ed72f2807549a23e94b3841fbe24a9f7009c241d0d1a5e4f32ac472c14009007298826ef92e7b282bec428c6421f5ccef551caa31c00896e274a1b24524a017d8159d51390ed7b2c74786574ac6db9aec2244cd0fecafb500f2bfe294ef7a2862e7e87d6b26f357d2bdb150d125335da6cb3721d33173aba006560b5ad2b3221b4536ff91ef47f60d92350a963d8a6a20c08abe49b8122b602bf4cc48cf8c667ad06beb51c04f0f4704428de7daf906fb39871fc3f72aea9158e6946cbf2e3b01e72c86f84938bbfa08a55f70a30407e5939c6ff67d64e3b85ded112f4b3f8069ef3b9287e422a5f26fff6e4ec62cababefb1ba3c797dbb7709e0e2147d52ed922807c395619d2a4068c7fbfd5fcb6b45d3e8d28446fcd1acfc35d5249320a07034b60acf345a635e9f4a58ab128c72e5c6c5e0c09b3ca6c069132bce440280c58f8871ccb7f98cc0bd40206be0748f89f542892462653485aaeafd12b421953368c626241cf7bf1ceb51b992044163c7162fa32b2c861a98e3f31a6c3085659f6bc4c1ac924ec78d371eae1b884419f0c201f1a643afba97c90f6bd8ce7eb5942600aec66c2d0d9bc8396b2d6dff9900e06be1bd73f8ba83f664100540d433c190639baecce4e0d327d12438cbf6f893fceafff9e80d79128e73d9d04d8ec68dff32550ea68aace3db49e7ff7d59173d321049b4336c7e1598ada5e759941165ecccdde75e625da10a084f7702c700e486e7277cdd9daaffae0fa6efc3fa5c43755ee20d51e713961df272bfb7ce25e15a55e04d2cb014b469c911e0a5aefbb698ae6d8b7fe9aa08dd184a1187465f5d690c51633e57b53b62c7226e81f1d69724cef685c739da669feac4849028dcbd8c86501d775f385393203dee5ea3ff6df5acbd4bf52de7fd169240826b541c261e91e5852c366b67b4cbe2435bd2b2754aea3f715358065444e9f486e18632c6cf18181d59c6bdf900132a6d11d00250516549a8fb654cf48165769687fe5c703e24ce2a77a4dcace9062a55c34272c9ba099120d29bd23a5d9f0c6c0f823c45361b6c36f9906d5283aafee6cf7bf9cec29bf9b6f806c690e4d4ba92f955bcbf3e86b6b26211101e080eefd996dd581733cc75f92ba6d7f2283caa47b6a0bf5cbb6494b872ddf706b7ce36704e08e3316a0642bf852a7ad279076fd453392e10e7ac7f78b5ba7ef5ae75f6a2a5ce26ebb42f0cdf032e229661a600128de51b6a2d90c491e08b85ccaec2e5be0ac5d8d737fbf50aebf7486cbf643bd1375d991823ef241c3551caff2231918c22e1631b1f56a1c8379e7ce067338e26ccdf09a06a2500205836f07839e0e11d757f6d9b5b7bea879b52ec1ba573331f11e543ee01a624220691098df5cbfb04057f840da0be73f65b17895a2838439eac09a602617605fd1659ef2eba304d7dcb37e48c326cb6cae9c8a646b57a7ea61ef377debc22545750cc6c8c834f55cca532f5177719e691d897abbf5994a7d1e99e83aedb098a53cd9f6dc18c480afa40fb20416be7a6e953241294537923843928e182c197cf98b0efdf58fd3996b95eab8dce8a5759f4fea389f71fa9df7dec92e658f19476f92d64b2b6a623c7cce35fdd7596caec818c0ee3b4c23cb597def90a7c629aac1c5795be9c1c01087e0c2d235d8e05589ec0ffe6d75e08b317db0dc304a20f30ecfc83c1d94b151f1c506ae2ab6e12472a58ef0235dcb47411d4247212969ef6628f1992c071e8f62ff7409b0041c01c2a5f59838c3f155734d06ebb8eec45bd30cebf585bbbffd3bc896158f4a64b98ad2bc2c04c805bfa75121723d20425f57f48ac9a3c54bd77da82f3e7d51dbc1de419bc6bbcd7a9a9a1dba34c9012ba1d0ea04e5582e9e634574c65879fdbe6ce45c1ab96cfa8aa428e8aa9fccea0f59f46e85de61ff665931e61f5808385d98a522b16b894eae7c4eecd00cc0f6ee28e71856abdcbced86e1c51def353aa75a1e8dab5c7633185aa376ddc8efc5d57473f4b98b8d06f9311b91a9176494d7ab17b2bb63bb8798914c5750065ae61675d37e32314208b3619308f56544cecadac4aad484fa85493e2f23c1b7e89c5643ed222a4e291341993a2e8ce59e8c8c1c914ebab1bb2b4849a41aee4bbd90cf9a8a9a315cdcd8bd4ea2f97689c1e4d94532fe52122419854df2c73baeee7c44c3ddb1edbade9ef551dc9ad7cbbec03f4088ee210743de8559b09677379c2c1a429203931ba5b7e6b59d66636298a64d0c6532be6a6bd840cb5af53ce062e11f5c471e9d4fb3d3bd82826eed449587ca4fda3ccb64b03cfd4d3d1e76400c40e3cf20f48a06533b774dc3e45e12fa7b61a200b43a446230575ba4cdb153333d8d8a8028d3fcfbbc5be549c9f048009b9200d67a17580c6817eab5bf5b7dc13b3f5c8056cb4a41ad13b1e54deaec549484f4f3334565d9c9022b948cfc6968fc4842ed73d1e3d5dc480d2a211734cd3919905b4ddb477690e8fe337c975c652604a983228b2c4565f34381b82e8afe322392df71bc9e35c9331b3957ebd1e156ce4715977a2e8c5b88a2731626082ce2785c334642ff5006fbc560394c1",
    stake: BAKED_STAKE
  },
  {
    address: "0x1b94d1fd23b176a71ec12bc29e6f766b40cb7af3",
    pubKey: "0xee53993a0feaab770e6e13629ee6451c476f96ae972490fa4d3523ad23f082d8e5958bd04c0a90aa5290cece929f8ec9dfeb58df8a22ad06158ebee13d6110dbeb2251117f484354b580e078d808b82ef7a477f9a16e67029268ff96884816814c5e752015f6a1aff18e04399a70468ebc9471b32a61d50c9690f1f23fb565c58b0b60137501be66ff7881b48ebb8e98f154473ddc6ce9c85c51a922e314bdfa11e89672d5993cb367217eb28d62f2237b34ce1278510a0d813d7c1b1976834869f69291c2f6eb038a410b1f4545c1cf253dccd73d6b72522d5199112e0e21a039872871b2922ff1d86ce1aea43d9111a017a7e2a7360210d1807337f54212da293ff1c2735c69c670c664dcaa68bf943b0bea0f37ab708c2b600e91cd748bd45b17c24b22993a1a1a76e644b1663e59d538ee99aabd1a3ca5c06a9dcdc9226c986894577c680ace606f8944841fa60d2cf9855391149ad13f29cd6bce5bc8d9aa0192a553bdfa83a35f7a4d947f2cd51758703af27d96e931946a2398a8bd7ba3a06db7f3ba2cbf8b91f0fe8c9c6750ed097fa7b62738984bc89d4e1810b815a479b4c4e133e8dfb4e34af54cd9f82c0b746d739e674fa93ac0b946c81d313292de3434cb3312464ab9aacf78f898b4976f2a8b37e2d789cdbb592f0fdadd158e13434be624e8d39cbc9e2321c0a1ed0d157934c65884665850d59894411b3b72b15f9438cae47e8ff4dced22a46e1aea6a65d2bd4ce702967edd4478176153aac53d7175a384cc8aeee44113624d78b150e93eeded321f405794cdb3f05ca0b182e05e8dea389389c8e084f682e7160f041e56d0df355cf12b7414ca5339e5274b977c9876f491d24f328e5138df8f2ff629efcd95e7ea7f6e1122f66ca2ce895419ca51676f028783d74ee2e2dca41fc868ff9cd37408cb2c73bea094084d8515d94bc05db388a59d615969d8f70e2fa2662386bee6a5edb29fdc21fad95d4c2d124a91f29607de604320b0c9b3962683c8ebef684bd57b6ff4b9c4896b7287e26652b49d5817b1cbe27c9edfa89d6f85cf974f751c0ca05b18fb9c72b3056024c0a2d9e3c471b4fb6faab0b819b0b06860e4bb9913a0d7d58fd32eb2716e02db8a51762d520e8c298c8284f0b4e912f00a156c7bd301d4115d6ff01e776548bdcad2ba35858127b13d4529717badecfcdf50cc29838a10a801794b385d807b5493c2c991ac675091c48b5f56944ad859042639e3753e1750c67f3d02ef7ba52cff9611e9cff4a1c517367c2a0a0c6985777249da13e654861d3210ee1861edf453d446cd226b23ba1dc3c4c9f995111d999cb0ae07fd84bde86aff1e03939b350fe69184e29f9582384422cb9d2302587767f1326fa2bc64db7d291a7c3ba56058714fa4e6b5a1b5e7efb0ef71e30947b967a00d556b8852feb627284df7b215678480184e1e31fa1b24dcfed6071a2b9906198794fe1a7b33cdfb0dbe813948babbe37ae6a71c4fb99c84cdda6bb2b6dc204c22b87318873487397f32248794ae198d118ec694a78928769920104ceef4235ba629276fb59e43531eb31a757e5d92874398f9103259031b47ced83dab50e3b3f6cbfa314e98607f45db59db679147ed9a1bc74851ab887c9d3938c450514c9e4fd8e17983d9e45fc5af440a505ba15f825c30814deb317b47eab93283964c6752f32c753e7c23cf0ff13c1328a5ec97d4b176f73e9e5c3244caaa867506df7e69bbf082b47aed49e6c8aade5a175ecfd6c30b16da7a3948afa2142f66fa65ec79ffb1cdb68e0c4976a3ff34389906624bb2e1d46c0db5aeb5b1ad85ae7f216690cd7b7208e6491ca9675f4274147f6f453ee374caf9013504bbd4e7172279c8954c5b4778b79b900cbff6862c2c7c9742ea23d5542e2e87093a447e743d1756c732fdc885a747f3f80071fe733836326c0427b3a6d2a92987d115dc0964fc21c1a5c05af50f5dcaa0c2e23efb42b20a814cefc58d8b38e7db36835a3d28b7f2c2127df730f00fcc3ac325acac25cb41fbb7242b052915166d8cc7fdb7fe52cc6fa2e8281a53ecb9c08ad01b96966bb7ca60c5b286d9d6874e1e6942c33c5bdd7dbaa9e8f1f2103f75d090f82d7b1c072e217a0ddee2c0636c1412883f76b0074e36ff87b4a0e5bd0952e13a79be630d9c46792c99b95ab8dcc8131653820dd8f3695ed6bfb039c8b03f0bba9f7723244360195ec715a16efee8ecee030880777c6082bcb3184d75728d39dbef08eaa49211be13fd74d18c57a76496f7dc23890c8340b196046025151a0f9ed9dbf6d4dc60cfe6470a239c9366d5a1642449da4dccbc25db3a07bb48fa54c9cd4643d0292f0a1fd440710bfbe95a911d454dce448ef56c3bd1dd1113adfeb08d0b44498624b5eb3bd0da9158a09727d1f23762af158d7fc12afc48cbdeb9eda046f91aaeb3332bf77fc26a8d10e14b39ac09893e4690665ee6bd71f24182ed2b12407dc000e295df3523c9cc27f35530b77537978b5a79402495748a8921a52e35a2bc0b807c2ebfe91032041d1685e57174935fed1d5b19b6981940c6f6e6c215e6a0b9144f3a9bf010259851dbd67ed05c03bc2a2a76030554be8337f2ff6aac53f67fd605cfd6edafd578e7f1837f780cd58a36f8e2cd91b04294a4b8567c7b3b0e572c0bf35cede06fabe74995040edd7992f2d1334cdd3b335c6d9e0e30ff54d34fb5421f6dae27db285dff13d654ddfe4f6c225d65abd41ed062c1",
    stake: BAKED_STAKE
  },
  {
    address: "0x46d306c0851b8ca9934ee9bcb7d08702db81863b",
    pubKey: "0xc2869ded2969a4b2e3ac3debfc359653cc681c0ce5c34088e6e94c751bac51a11b2e656f3a67656c5e5ef96a52040a25c73225243199ddfde1ef0e18eda232f4e32ac32354b97e3342f2a96dec28949cc5c6c021728596fba0c490749aec709f6e55cb28e4aa7f78967ea37060069591055532b16db908d82772ec377e2219b17799236499b82af6dd1899ea23fe18b0b80296c802c4cfba52821f5fcac37e91be32e595866a707a97fc7f206d49337d5f60b994f9289ac45e65f8451d93d64b44a10ec6c2ae9ae31e4ed5fdcf044aef654e9935b79f064a6bb690bb88ce5f88a718937e92313d327e3d95f88be570c0faa77ce867b8fb5eec30a083cee06b61eae2136fbb4ddb0f6581466b555af0ee6204827a05d33923952bdb10d625f6173b98e869e8de6140bca8cadec5a03f7af6981b6b7cf3381717f60f1f6e3d45099efc17778189d8d4d19ef33c3789f4b2dbfbe7608a6b1aab42abe08fe6b72d304bb75f067eb1de212da9c4f586f214d0fdc80f132913063aa80a5ee7d1a88713be782f70d3ca7ab2e8879587b307b1c8a7dd4a082c7f34d3af6de5583cd75a3bac2216f9626e03122c8d2d976849befcbd6d36defe36d84d7d120628cb87c561f4c0e81d6ee396cf035d715d3c21e0ce1d57b220768e2203a084dc80ed8a920c4ad8dc80ce33a7f4ef1e0f419a24ab774ba7e06d9b966d9b5215111b76ffb50454ede60da746a1c2f08b5e055c30883d1881fba2af29cddbebde6e8495431f2dba8fb02859bad8100da178470284948605658c48f02d52d3f4753b7ff283239f93140fab869b48a48c8e5d02f2e1ec2a01158e4583e6c682e0ec0b7448a6cb497d4cbf447e9af9af5f9094396d6f8ee7f7335af3486d787b2523f48488ea8cd49341426e19dce16f214ed3fab3be9ac09ab37d3f1bb2b36b995c3c1238a814746d98a466b73116f473bfb287bbc3ce3c9dd57001f5a7bed1b1f1e67299984b672549152e945b4555ede82d0a9009524e610b634f7a5030299d1d4da0c171f7a1178c53bea0879d6e2f1330d36d4532c304f48bec64b0db3602344df7f50e26c4bb4003852777ca9dc584e86f6392e0e7baef088053ad9d5a8995d2f2a12fa4eec3e0ad8241aadbc17ebb69bcdb9828edab7b4abddf21e616b4d4e7f528232d8aea2740d6a05b192610595698681e52ec90b01a8873961367653fe1dd6b763f497d0a2224001791c8073947295ab0ac1789e3e9480d53c176cec1bd5589deb13d3c70aeee61a1d271572c19fe3a3a32c02be67a4d0358e665a20de51781f7904f300a220d839b0c3f316df223047c1246211d836c321a1ad07d7bae1a20d5568570a91731e6281e08cc32d3b7027dfe43970fcea9e7ad5b2d8249d16c1e91673fecf6ac7353c47032f57e9ac39e8681dfe637488a8951e61420e8ceee9fe21cf420e074d353c14d06260414199218269321de28494f69e4f39b83a96674e6d32d4aee3ea39decb44c5696d09996d5d6e8fb291414b0097a188d6c96a44e9a6e91cac4e673ef4168e5b40e659fdcd98afe06499ce0841b6ea1dcbf657137996b6a57e7a2eaedc0a4b1bc918951028062e3c3393e0cd4bba99f3720fe40cedc423e8c6386a641d408af6eb04be8fc0b17273e59391bbe32bda01d7347686e9537af84492de8196c0da08a25331145ea1a109c0fd7fe31338895647de300ef1d8fc7b74b6dc742fd0b8a94f231594ca014a02c0e935d553798e1672f506bb77d3eb1aa72146f697f2b4beb175b8de4d71acc74470ee5af696f82d94d382dfe40dc97708bee4883f495c90de1974704a60850a0e71b61fbbda5538ba61e86c7bf466ba93d3f2b01ecc7c2a440559485964f53515685a64d8e7afb73a234bb43f29932c9bba18312dab1a6df8e5f8e989cdee9eae07819b7a722e4c32fec4e9c4de9833b1aa8814f3ea89e28466f8380bcac4d7d6026028d3a328ab8424cc63f5e8ee897caaaaaa7d09c0a2f6070d39c71f2e97779cda577f7a5bb727ecd17b75e1c3c3cae4e08914ae64c0f220ee19f33fa7d933ec18847293ce826bcf3e2a9040683c9fd854e506945eb1875df36db02ef04c6ebd2db0221c6fd7a9355dc50e7075afa9226cdcefaf79ca6cb7bd83be300b2c3adb561dd3f04aca31a38a2735acaad4122f5b54510dee131421e224d9bc18d56661a5b08c8cd197c4a727fa7e7c3793e01911d45c665963c4052fc1b94c85665d43adc94e946a07003db7240041e506adae8594561e28c18e71639873a0968fdc16dc19f3b243245da393cd744b154a0575cb526907cec56acccc1d6be926d12ecf9398080a24865bc01b967ca5271a1eb1aab0df656acc70197757441ab5b200cc8a66d919ec3b90fa99e29a941bf923ad5b371ae1616fb7756cb09fa4b6cdb2fb62fd7c8b443969de72a1d8aa91aadfbf4f17761d48c8bfaa8f1bb2b9a75ce236a8adefce664afe1b9e3ef6c55f1c38e8d4e2a7f1ff034f7c4d03019c38e1fdcfdd92411e27d84af96126c02596ebdafffc0bd1225284930e5bb946bbb18ee3cfb97c0b0b271b3c60855bb5e1a6dbde2e89187c77251be53036c49a1aeeef596cfd6d5545b6186a5a709f3ee8330f17899a80bf27e66d45bb6c4f031c000afa7998de1895b633383569cd5466ec7b3826416b16a46d04d4f8d6c45c7e7e8f713fe798fd3a20f82c02eddf7da229306027cd5273c77878d7c99eedcb0516d8dc45b3c948727c2acaeb4435abc3767",
    stake: BAKED_STAKE
  },
  {
    address: "0x9b777022a37a926bccc2432992c3a06b683eb439",
    pubKey: "0x5768eaf3365a2411b16b21e30e91c7b9aa44b9270b0e19689b766651136118deb2c8e1488ebe082447425729dc2a0592ee33b43341228ecf2bdc219a4fb13813aa8241f6cd0a9817b75e8ca029f7db98271c0ca1297b3ca8fdc6e9270cd73324c588a702ad648e5c81db442adba74cf08708450041fefe241bf6c0c8691e9e14d884bf2b84c0ee5cda118a982cf931e529b2ee80d98e34f1db1dd775aa4f1a4435cf06957c956b4cfbbb597ee947272d6d1acc9dd5646256b488b4256522016b3c0fc49970951256104e70b72c954177506a29c9a73d35ba313f030e365bb84e29eacd4417e4cfe0bccca9c1614b88e452c89f6f4a323d17d86efc92e2b20b43136573e4c139b0039a3904864e946544d4dd648fc065c3f967a5699b0ed5ba5457df570dbbd3eb3e08e0943488fd5d103f3bcb659eb42eba06488480d407af0e8eaba8ccfc24536c0da0973052f057f63f31562b26aa9cf6ecbf2ec25cab60c465e81c0535de417f978fd8272910639333a37a89513b84982386770f6eeb1b7446073ce7b645e5351e4a4510aa93e76655557e2b7fae18928055bcd6be255d61312efd604df731ec83806ec7fdea0d208c93375c9a12455a11d1350773319104e92efd9ae0233f32411e9402f865874c2e6d4203254733499a0983d2c0d9bf89517146b3307600e8afa3999e8aa7b2b0b1893fd14c92b124770b3ee38318e2bc59892ca6f848b8de5c69a59fc12085de6ad5b33fef8ac454293f5c7132903229095942f1af34d17e7d699dd5621ec3fa0230ed34052ced5bd45782d799917c1ee04ba0967d12e9e592a10176992232c10300d7015d3ade25585aec6528ab14165dfd4df3798905e18ca201351f196752e888a20849333aecdae3149f16e37e581935adcfad7d86bbe7f92313fd1b302f677b68f48c1e3e9c0b48ff009b208bf377248c10c1fefb146ea1fc3c674dd15de8f870dc93d61b80f429dbcce701c5e054f672432637f65f78987a35a9e5a1a4761bd3b80a706268dd3c790b87dbded1add9b31e87d8b78cced015fada39c61f1382ab28e0e1e0315a52e938aaa9086e783fdbd3fb5c4f45ecb408e022a2e0bbf274b812daf59a2538e60266b780c3ad7a7c7e98f2a88e88c6a08ba507e5a860ed8af038cfa77b3bc600935467d30e0898f3170abd471f1169f19f968ce77b811410daec04f147314a52fbf8fec008e502f1f59cdca8b18e5d8833ff13af3a3d527008308b54ca91c3952a997bf2257882d0ff1029d5ebc05a22d0ad8d5868f3f7447b85c6bbd4af5374b4a15642355d9cae4106887729db4d3604f161fa5b96b1b6bcf2db48f009871979cf9a37be04f0f59998dc758074f4b1415e7d224f286faf94db1646e63f5c47de2f8cec5dd8ebaef3338ba88d5e2744a5d6957a1db1fa40f0f423a396f4d93a348cd13fd51eac5e025223f0472e742d36c5d961608d974afc175af84f7c576b354e5bdce069f3fb7fe550446cf54218d3e0e36d02f2afc422463b5062177c05566564c904d1b0c5eba17f5e4c2ecfb261f575aa29e54d10bfede55e826454d1f9fd931222ea530a1242be82ed476bdae6f3234b80b532ed6da2a1230f6817574c03d7e29521cd8222ac51dfdf511484df4212b7cea215020834211edd23b2ce86fc7faef759177fe2b18fbdf6b583e76847a3df48e3dcddec91ded74e07ee266dbe8c153d623967912d840fe141c4fe35dd4632473c670c35838869cafb2b86015c9127d3986dc1fd44e8c3b28840c9c890d997ee5758f3874a82670c4690c49b6443ec42526e0f3555fc2870a737323a0f808ce83178552f7c93ea577324b2309e23dedac3b7be2064ebe799afda8179c6b2413cac190bfbe12412939724f42f419e052f4664e5bbf43e6224e689bcf0c5968825d413797df7b3c2f9e198b7e3ebd370cdb46999ad8ff4c2de210797bd3fe0b6746149ea17807b046563b487918607c6da71775822cf3d6fbe4759121dc8bf2fa5a6a3d3f34db5bc5b4a5baf9716443c8cd208dc4b63cf8c1b1c8796c2cbc8024dd57e0110046e1df2a7c916ba06e7c1219c566643eeb6350dd94e72c974954f15a81c01d9c8706027887687b35344cc676a92107595bcb55f9cf28cd54f2f62fcae1581389bb09d55756f063a95cb853163318cb231a296d3a193b9b78897ecd81c2d6e6da847de7ee71c0c2d762282a0c31e00925f5ec6523971a75215f5f970a2d4c87011d6f5a5ffcb329fd75701b3a12a24975922886af167f6b29460453c93596bde0ead73dae3940f8987b969e181b17913fc4a9295ec16db780a15fe1131f04a22bea900b86b09b7d43f693e6a9268a426e2cdd9aeb1da34c6c6dae4d960daac56203ba918c7bacc125477d92d538328eed3707478eec01a2b06d5e9f0e4c8cf2e8d2918fdf64098fa10eb3eb5035136e682503415a0e4c4fc7236009c7e9177591669872d00edbdd351808d03452121683ae7bffd9c45c035095cd6a24d84df6ad7dc2a0396f60b7bf188eab39df4e3d2509d3d6edfb47a4198195761dd172043a679caec5e6db1edf3be833f70fcc793cf5406661fab6330dde37d2828f75984bf81d0466b4796ac57a97873bf5826be5d35038c62e784c3f4c6d90a69609dbec7bc8c1d0156cea49d7b966d4b01b186c619e8b14b783beec40ce69b21172917b6f5943a24ed53e3c96f9d29f6071936a21c4c63bbafc5f1a9ebdf37c57c1e4c40ca4c2ecbb5d908ab042b0fcb",
    stake: BAKED_STAKE
  }
];

// src/chain/genesis-spec.ts
import { readFile, writeFile as writeFile2 } from "fs/promises";
async function saveGenesisSpec(path, spec) {
  await writeFile2(path, JSON.stringify(spec, null, 2) + "\n", "utf8");
}
async function loadGenesisSpec(path) {
  try {
    const raw = await readFile(path, "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: bytesToHex6(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: bytesToHex6(opts.genesisHash),
    genesisStateRoot: bytesToHex6(opts.genesisStateRoot)
  };
}
function fromGenesisSpec(spec) {
  return {
    chainId: BigInt(spec.chainId),
    gasLimit: BigInt(spec.gasLimit),
    baseFeePerGas: BigInt(spec.baseFeePerGas),
    initialAccounts: spec.initialAccounts.map((a) => ({
      address: hexToBytes2(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 bytesToHex6(bytes) {
  let hex2 = "0x";
  for (let i = 0; i < bytes.length; i++) {
    hex2 += (bytes[i] ?? 0).toString(16).padStart(2, "0");
  }
  return hex2;
}
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;
}

// 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 key = address.toLowerCase();
  const prev = payouts.get(key) ?? 0n;
  payouts.set(key, 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 } of keys) {
      const raw = await store.getStorageValue(VESTING_POOL_ADDRESS, key);
      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, key);
      } else if (newlyVested > 0n) {
        await store.setStorageValue(VESTING_POOL_ADDRESS, key, 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 blake310 } from "@asentum/crypto";
function quorumThresholdStake(validators) {
  const total = totalBondedStake(validators);
  if (total <= 0n) return 0n;
  return 2n * total / 3n + 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 = blake310(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/engine.ts
import {
  VOTE_KIND_PRECOMMIT as VOTE_KIND_PRECOMMIT2,
  VOTE_KIND_PREVOTE as VOTE_KIND_PREVOTE2
} from "@asentum/types";
var ConsensusEngine = class {
  height = 0n;
  round = 0n;
  phase = "propose";
  validators = [];
  validatorSet = /* @__PURE__ */ new Set();
  /**
   * 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();
  /**
   * 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();
  /** Dedup set for stale-vote rejection logs. Repeated stale-vote spam
   * pinned the event loop; key is (validator, round, blockHash). */
  staleVoteLogSeen = /* @__PURE__ */ new Set();
  /** 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;
  /**
   * Validator addresses (lowercase 0x-prefixed) whose precommit for
   * `lastCommittedHeight`/`lastCommittedHashHex` arrived AFTER the engine
   * had already advanced past that height. Drained by
   * `getLateCommittedSigners()` and folded into the next block's
   * lastCommitBitmap by the producer. Cleared on the next commit (when
   * `lastCommittedHeight` moves forward).
   */
  lateCommittedPrecommits = /* @__PURE__ */ new Set();
  constructor(chainId) {
    this.chainId = chainId;
  }
  /** Reset state for a new height. Called each time a block is committed. */
  startHeight(opts) {
    this.height = opts.height;
    this.round = opts.round ?? 0n;
    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.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.staleVoteLogSeen = /* @__PURE__ */ new Set();
    this.committedBlockHash = null;
    this.quorumListeners.clear();
    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.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 key of this.pendingFutureVotes.keys()) {
      if (BigInt(key) < this.round) this.pendingFutureVotes.delete(key);
    }
  }
  setPhase(phase) {
    this.phase = phase;
  }
  getHeight() {
    return this.height;
  }
  getRound() {
    return this.round;
  }
  getPhase() {
    return this.phase;
  }
  getValidators() {
    return this.validators;
  }
  /** Who should be proposing right now? */
  currentProposer() {
    return proposerForRound(this.validators, this.height, this.round);
  }
  /**
   * 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 verify3 = verifySignedVote(vote);
    if (!verify3.ok) {
      console.log(`[engine] vote REJECTED reason=sig: ${verify3.reason}`);
      return { ok: false, reason: verify3.reason };
    }
    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 && this.lastCommittedHeight !== null && vote.body.height === this.lastCommittedHeight && this.lastCommittedHashHex !== null && bytesToHexLower2(vote.body.blockHash) === this.lastCommittedHashHex) {
        const lateValidatorAddr = bytesToHexLower2(vote.body.validator);
        const alreadyBuffered = this.lateCommittedPrecommits.has(lateValidatorAddr);
        if (!alreadyBuffered) {
          this.lateCommittedPrecommits.add(lateValidatorAddr);
          console.log(
            `[engine] late precommit BUFFERED from=${lateValidatorAddr.slice(0, 14)}\u2026 for just-committed h=${vote.body.height} (will be folded into next block's bitmap)`
          );
        }
        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`);
      }
      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 valHex = bytesToHexLower2(vote.body.validator);
      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`);
      }
      return {
        ok: false,
        reason: `stale round: vote ${vote.body.round} vs engine ${this.round}`
      };
    }
    const validatorAddr = bytesToHexLower2(vote.body.validator);
    if (!this.validatorSet.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 active set` };
    }
    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 === "precommit" && this.phase === "precommit") {
      this.phase = "commit";
      this.committedBlockHash = blockHashHex;
      this.lastCommittedHeight = this.height;
      this.lastCommittedHashHex = blockHashHex;
      this.lateCommittedPrecommits = /* @__PURE__ */ new Set();
    }
    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);
  }
  /** 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 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 the just-committed block —
   * precommits that arrived AFTER the engine advanced past that height.
   * The producer of the next block unions these into the bitmap so slow
   * validators still get credited. Returns lowercase 0x-prefixed addresses.
   *
   * The buffer is automatically cleared on the next commit.
   */
  getLateCommittedSigners() {
    return Array.from(this.lateCommittedPrecommits);
  }
  /**
   * 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 totalStake = totalBondedStake(this.validators);
    if (totalStake === 0n) return;
    const polkaThreshold = totalStake / 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 bytesToHex7(b) {
  let s = "0x";
  for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
  return s;
}
function hexToBytes3(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), hexToBytes3(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 ${bytesToHex7(existing).slice(0, 18)}\u2026, attempted ${bytesToHex7(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: bytesToHex7(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 blake311 } 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 blake311(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 = blake311(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/node/block-store.ts
import { ClassicLevel } from "classic-level";
import { blake3 as blake312 } from "@asentum/crypto";
import {
  BlockHeaderSchema as BlockHeaderSchema2,
  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(path) {
    this.db = new ClassicLevel(path, {
      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 key = blockKey(block.header.number);
    const value = BlockSchema2.serialize(block);
    const headerBytes = BlockHeaderSchema2.serialize(block.header);
    const heightBytes = u64be(block.header.number);
    const blockHash = blake312(headerBytes);
    const batch = this.db.batch();
    batch.put(key, 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 BlockHeaderSchema2.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/node/dev-validators.ts
import {
  addressBytesFromPublicKey as addressBytesFromPublicKey5,
  generateKeypair
} from "@asentum/crypto";
var DEV_VALIDATOR_COUNT = 4;
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] = 48 + index;
  seed[2] = 222;
  for (let i = 3; i < 32; i++) {
    seed[i] = i * 31 + index * 17 & 255;
  }
  return seed;
}
var DEV_VALIDATOR_STAKE = 1000n * 10n ** 18n;
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, key) {
    const tag = hexOf(contractAddr) + ":" + key;
    const buf = this.storageBuf.get(tag);
    if (buf !== void 0) return buf.value;
    return this.underlying.getStorageValue(contractAddr, key);
  }
  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 key = tag.slice(prefix.length);
      if (buf.value === null) merged.delete(key);
      else merged.set(key, buf.value);
    }
    return Array.from(merged.entries()).map(([key, value]) => ({ key, 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, key, value) {
    const tag = hexOf(contractAddr) + ":" + key;
    this.storageBuf.set(tag, { value });
  }
  async deleteStorageValue(contractAddr, key) {
    const tag = hexOf(contractAddr) + ":" + key;
    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 key = tag.slice(sep + 1);
      if (buf.value === null) {
        await this.underlying.deleteStorageValue(contractAddr, key);
      } else {
        await this.underlying.setStorageValue(contractAddr, key, 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();
  }
  /** 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 emptyAccount2() {
  return {
    balance: 0n,
    nonce: 0n,
    codeHash: new Uint8Array(32),
    storageRoot: new Uint8Array(32)
  };
}

// src/node/mempool.ts
import { blake3 as blake313 } from "@asentum/crypto";
import { SignedTransactionSchema as SignedTransactionSchema3 } from "@asentum/types";
import { addressFromPublicKey } from "@asentum/crypto";
function txHash(tx) {
  const bytes = SignedTransactionSchema3.serialize(tx);
  const hash = blake313(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 = txHash(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 mkdir2, readFile as readFile2, stat, writeFile as writeFile3 } from "fs/promises";
import { dirname } 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 readFile2(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 mkdir2(dirname(opts.path), { recursive: true });
  await writeFile3(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 = 6e3;
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 = 1440n;
var COMMITTEE_CAP = 100;
var EPOCH_ENTRY_WINDOW = 100n;
var AsentumNode = class {
  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.
   */
  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;
  /** Target height the running catchup is climbing to. */
  catchupTarget = 0n;
  genesisSpec = 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;
  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;
  /**
   * 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;
  /**
   * 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();
  /**
   * 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 ?? 4e3;
    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: join3(options.dataDir, "state") });
    this.blockStore = new BlockStore(join3(options.dataDir, "blocks"));
    this.vm = new Vm();
    this.consensusEngine = new ConsensusEngine(this.chainId);
  }
  /** 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 mkdir3(join3(this.dataDir, "state"), { recursive: true });
    await mkdir3(join3(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 = join3(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 = join3(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;
        if (this.devnet) {
          initialValidators = getDevValidatorSet().map((v) => ({
            address: bytesToHex8(v.address),
            pubKey: bytesToHex8(v.keypair.publicKey),
            stake: DEV_VALIDATOR_STAKE
          }));
        } else {
          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
          })) : [
            {
              address: bytesToHex8(this.faucet.address),
              pubKey: bytesToHex8(this.faucet.keypair.publicKey),
              stake: FAUCET_BOND_STAKE
            }
          ];
        }
        const TEAM_GRANT_ADDR = hexToBytes4("0xe60798d5da90ab06aa32a3f680bca423bb4bd74d");
        const TEAM_GRANT_BALANCE = 5000000n * 10n ** 18n;
        const BAKED_WALLET_GRANT = 25000000n * 10n ** 18n;
        const DEV_WALLET_ADDR = hexToBytes4("0x9eC9174B597A606a095bd6B94Fe53B2Ed6dB17C6");
        const DEV_WALLET_2_ADDR = hexToBytes4("0xe16cb40df50e2ddca2be53d329fc5e25e0e44df6");
        const DEV_WALLET_BALANCE = 50000000n * 10n ** 18n;
        const bakedWalletGrants = BAKED_GENESIS_VALIDATORS.map((v) => ({
          address: hexToBytes4(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 }
        ];
        const initialAseHolders = initialAccounts.map((a) => ({
          address: bytesToHex8(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 = blake314(BlockHeaderSchema3.serialize(genesis.header));
      if (expectedGenesisHashHex) {
        const localHex = "0x" + bytesToHex8(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;
    await this.resetConsensusForNextHeight();
    if (this.latestHeader.number > 0n) {
      this.prevBlockMeta = await this.reconstructPrevBlockMeta(this.latestHeader);
    }
    this.consensusEngine.onDoubleSign((ev) => {
      this.handleDoubleSignEvidence(ev);
    });
    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).
   */
  handleDoubleSignEvidence(ev) {
    const offenderHex = bytesToHex8(ev.offender);
    console.warn(
      `[slashing] DOUBLE SIGN detected: validator=${offenderHex} kind=${ev.kind} height=${ev.height} round=${ev.round} hashA=${bytesToHex8(ev.blockHashA).slice(0, 18)}... hashB=${bytesToHex8(ev.blockHashB).slice(0, 18)}...`
    );
    if (this.role !== "producer") return;
    try {
      const voteAHex = bytesToHex8(SignedVoteSchema3.serialize(ev.voteA));
      const voteBHex = bytesToHex8(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;
    const validators = await this.fetchActiveValidatorViews();
    this.consensusEngine.startHeight({
      height: this.latestHeader.number + 1n,
      validators,
      round: 0n
    });
    this.armProposalTimeoutIfNotProposer();
  }
  /**
   * 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();
    const proposer = this.consensusEngine.currentProposer();
    const selfHex = bytesToHex8(this.validatorKey.address).toLowerCase();
    if (proposer && proposer.address.toLowerCase() === selfHex) return;
    let timeoutMs = this.proposalTimeoutMs;
    if (!proposer) {
      timeoutMs = this.deadProposerTimeoutMs;
    } 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;
      }
    }
    const scaledTimeoutMs = 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 = bytesToHex8(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() {
    const oldRound = this.consensusEngine.getRound();
    const height = this.consensusEngine.getHeight();
    this.consensusEngine.advanceRound();
    const newRound = this.consensusEngine.getRound();
    console.log(
      `[bft] proposal timeout at (height=${height}, round=${oldRound}) \u2192 advancing to round ${newRound}`
    );
    const proposer = this.consensusEngine.currentProposer();
    const selfHex = bytesToHex8(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) {
    const candidates = await this.getEpochCandidatesFiltered(epoch);
    const totalBonded = candidates.core.length + candidates.reserve.length;
    if (totalBonded <= COMMITTEE_CAP) {
      return [...candidates.core, ...candidates.reserve].map((v) => ({
        address: v.address.toLowerCase(),
        stake: v.stake
      }));
    }
    const seedSourceHeight = epoch === 0n ? 0n : epoch * EPOCH_BLOCKS - 1n;
    const seedSourceBlock = await this.blockStore.getBlockByNumber(seedSourceHeight);
    const seedSourceHash = seedSourceBlock ? blake314(BlockHeaderSchema3.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));
    return [...candidates.core, ...picked].map((v) => ({
      address: v.address.toLowerCase(),
      stake: v.stake
    }));
  }
  /**
   * 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) {
    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));
    };
    if (this.prevBlockMeta && lastCommitBitmap.length > 0) {
      const prev = this.prevBlockMeta;
      trace("prevBlockMeta", {
        blockNumber: prev.blockNumber,
        proposer: bytesToHex8(prev.proposer),
        committeeAddresses: prev.committeeAddresses,
        committeeStakes: Object.fromEntries(
          Array.from(prev.committeeStakes.entries()).map(([k, v]) => [k, v.toString()])
        )
      });
      trace("lastCommitBitmap", bytesToHex8(lastCommitBitmap));
      let signerAddrs;
      try {
        signerAddrs = decodeLastCommitBitmap(lastCommitBitmap, prev.committeeAddresses);
      } catch (err) {
        console.error("[rewards] failed to decode lastCommitBitmap:", err);
        signerAddrs = [];
      }
      trace("decoded signers", signerAddrs);
      if (signerAddrs.length > 0) {
        const signers = signerAddrs.map((addr) => ({
          address: addr,
          stake: prev.committeeStakes.get(addr) ?? 0n
        }));
        const warmInactive = await this.computeWarmInactiveForReward(prev.blockNumber);
        trace("warmInactive", warmInactive);
        const proposerHex = bytesToHex8(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()]
          )
        });
        if (distribution.emitted > 0n) {
          await this.applyRewardPayoutsTo(store, distribution);
        }
      }
    } 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 committee = this.consensusEngine.getValidators();
    const committeeAddresses = committee.map((v) => v.address.toLowerCase());
    const committeeStakes = /* @__PURE__ */ new Map();
    for (const addr of committeeAddresses) {
      committeeStakes.set(addr, this.consensusEngine.stakeOf(addr));
    }
    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: bytesToHex8(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;
    let committee;
    try {
      committee = await this.computeCommitteeForEpoch(epoch);
    } 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: bytesToHex8(header.proposer),
        committeeAddresses,
        committeeStakes: stakesObj,
        locallyObservedSignersCount: 0
      }));
    }
    return {
      blockNumber: header.number,
      proposer: header.proposer,
      committeeAddresses,
      committeeStakes,
      locallyObservedSigners: []
    };
  }
  /**
   * 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).
   */
  buildBitmapForNextBlock() {
    if (!this.prevBlockMeta) return emptyLastCommitBitmap();
    const lateSigners = this.consensusEngine.getLateCommittedSigners();
    const merged = new Set(this.prevBlockMeta.locallyObservedSigners);
    for (const addr of lateSigners) merged.add(addr);
    return buildLastCommitBitmap(
      this.prevBlockMeta.committeeAddresses,
      Array.from(merged)
    );
  }
  /**
   * 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 epoch = blockNum / EPOCH_BLOCKS;
      const candidates = await this.getEpochCandidatesFiltered(epoch);
      let committee;
      if (this.cachedCommittee && this.cachedCommittee.epoch === epoch) {
        committee = this.cachedCommittee.committee;
      } else {
        committee = await this.computeCommitteeForEpoch(epoch);
        this.cachedCommittee = { epoch, committee };
      }
      const inCommittee = new Set(committee.map((v) => v.address.toLowerCase()));
      return candidates.reserve.filter((v) => !inCommittee.has(v.address.toLowerCase())).map((v) => ({ address: v.address.toLowerCase(), stake: BigInt(v.stake) }));
    } 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 = hexToBytes4(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 = hexToBytes4(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.
   */
  async maybeSubmitEpochEntry(committedHeight) {
    const currentEpoch = committedHeight / EPOCH_BLOCKS;
    const epochStart = currentEpoch * EPOCH_BLOCKS;
    const windowOpensAt = epochStart + EPOCH_BLOCKS - EPOCH_ENTRY_WINDOW;
    if (committedHeight !== windowOpensAt) return;
    const targetEpoch = currentEpoch + 1n;
    const senderHex = bytesToHex8(this.validatorKey.address).toLowerCase();
    const account = await this.stateStore.getAccount(this.validatorKey.address);
    const nonce = account?.nonce ?? 0n;
    try {
      const tx = buildAndSignCall({
        senderKeypair: this.validatorKey.keypair,
        chainId: this.chainId,
        nonce,
        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}`
        );
      } else {
        console.log(
          `[epoch-entry] submitted for epoch ${targetEpoch} (validator ${senderHex.slice(0, 14)}\u2026, nonce ${nonce})`
        );
      }
    } 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) {
    let candidates;
    const epochStartBlock = epoch * EPOCH_BLOCKS;
    const epochStartHeader = await this.blockStore.getBlockByNumber(epochStartBlock);
    const queryBlockNumber = epochStartHeader ? epochStartBlock : this.latestHeader.number;
    const queryBlockTimestamp = epochStartHeader ? epochStartHeader.header.timestamp : this.latestHeader.timestamp;
    try {
      const stakingResult = await runReadOnlyCall({
        store: this.stateStore,
        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 tombs = await this.getTombstones();
      tombstoned = new Set(tombs.map((t) => t.address.toLowerCase()));
    } catch {
      tombstoned = /* @__PURE__ */ new Set();
    }
    return {
      core: (candidates.core ?? []).filter((v) => !tombstoned.has(v.address.toLowerCase())),
      reserve: (candidates.reserve ?? []).filter((v) => !tombstoned.has(v.address.toLowerCase()))
    };
  }
  /**
   * 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. */
  start() {
    if (this.running) return;
    this.running = true;
    if (!this.manualBlockProduction) {
      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;
    }
    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.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 = bytesToHex8(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;
    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 {
      producerJournal = new JournaledStateStore(this.stateStore);
      const storeForBuild = producerJournal;
      const pending = this.mempool.drain(MAX_TXS_PER_BLOCK);
      const lastCommitBitmap = this.buildBitmapForNextBlock();
      const blockNumberToBuild = this.latestHeader.number + 1n;
      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,
        // Apply per-block subsidies (rewards + vesting releases) inside
        // the journal, AFTER txs and BEFORE stateRoot computation. This
        // makes subsidies part of the block's stateRoot so all peers
        // applying the same block deterministically arrive at the same
        // post-state.
        applySubsidiesHook: async (s) => {
          await this.applyBlockSubsidies(s, blockNumberToBuild, lastCommitBitmap);
        },
        // Run due cron jobs FIRST, before mempool txs. Mutations folded
        // into the same stateRoot so replicas see deterministic results.
        // Uses the BLOCK timestamp (not wall clock) so all validators
        // pick the same "now" when deciding which jobs are due.
        applyCronHook: async (s, ctx) => {
          if (!ctx) return;
          await this.applyCronJobs(s, blockNumberToBuild, ctx.blockTimestamp);
        }
      });
      block = result.block;
      receipts = result.receipts;
      this.cachedProposal = { height: currentHeight, block, receipts, journal: producerJournal };
    }
    console.log(`[producer] broadcasting proposal h=${block.header.number} r=${this.consensusEngine.getRound()}`);
    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 {
      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();
      }
      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 = blake314(BlockHeaderSchema3.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 = blake314(BlockHeaderSchema3.serialize(block.header));
    const round = this.consensusEngine.getRound();
    this.consensusEngine.setPhase("prevote");
    const prevote = await this.signVoteSafely({
      kind: "prevote",
      height: block.header.number,
      round,
      blockHash
    });
    if (prevote) {
      const prevoteResult = this.ingestConsensusVote(prevote);
      if (!prevoteResult.ok) {
        throw new Error(`prevote ingest failed: ${prevoteResult.reason}`);
      }
    }
    await this.consensusEngine.waitForQuorum("prevote", blockHash, this.scaleTimeoutForRound(this.voteTimeoutMs));
    const precommit = await this.signVoteSafely({
      kind: "precommit",
      height: block.header.number,
      round,
      blockHash
    });
    if (precommit) {
      const precommitResult = this.ingestConsensusVote(precommit);
      if (!precommitResult.ok) {
        throw new Error(`precommit ingest failed: ${precommitResult.reason}`);
      }
    }
    await this.consensusEngine.waitForQuorum(
      "precommit",
      blockHash,
      this.scaleTimeoutForRound(this.voteTimeoutMs),
      this.quorumGraceMs
    );
    const commit = this.consensusEngine.commitResultFor(blockHash);
    if (!commit.committed) {
      throw new Error(
        `block ${block.header.number}: no quorum after wait \u2014 precommits=${commit.count} stake=${commit.stake}/${commit.threshold}`
      );
    }
  }
  /**
   * 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) 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. */
  async runCatchup() {
    console.log(`[block-sync] starting catchup: local=${this.latestHeader.number} target=${this.catchupTarget}`);
    let peerIdx = 0;
    let consecutiveFailures = 0;
    while (this.latestHeader.number < this.catchupTarget) {
      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(), 5e3);
        let rawHex;
        try {
          const res = await fetch(`${peer}/raw-block/${wantHeight}`, {
            signal: ac.signal
          });
          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 = hexToBytes4(rawHex);
        await this.applyExternalBlock(bytes);
        if (this.latestHeader.number === wantHeight) {
          consecutiveFailures = 0;
        } else {
          throw new Error(`apply did not advance head past ${wantHeight}`);
        }
      } catch (err) {
        consecutiveFailures += 1;
        if (consecutiveFailures >= this.catchupPeerUrls.length * 2) {
          console.log(`[block-sync] giving up at h=${wantHeight} after ${consecutiveFailures} consecutive failures (last: ${String(err)})`);
          return;
        }
      }
    }
    console.log(`[block-sync] catchup complete at h=${this.latestHeader.number}`);
  }
  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 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();
      return { accepted: true, block: tentative.block };
    }
    const finalize = this.finalizeProposalBft(tentative).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) {
    const block = tentative.block;
    const blockHash = blake314(BlockHeaderSchema3.serialize(block.header));
    try {
      await this.runVotingCycleForExternalBlock(block, blockHash);
    } 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)
      };
    }
    this.clearProposalTimeout();
    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) {
        throw new Error(
          `applyExternalBlock: stale proposal, proposerRound=${opts.proposerRound} < localRound=${localRound} at height ${block.header.number}`
        );
      }
      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 = blake314(BlockHeaderSchema3.serialize(this.latestHeader));
    if (!bytesEqual5(block.header.parentHash, expectedParent)) {
      throw new Error(
        `applyExternalBlock: parent hash mismatch at block ${block.header.number}`
      );
    }
    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 = blake314(SignedTransactionSchema4.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
          })
        );
      }
      await this.applyBlockSubsidies(
        storeForApply,
        block.header.number,
        block.header.lastCommitBitmap
      );
      const computedStateRoot = await journaled.stateRoot();
      if (!bytesEqual5(computedStateRoot, block.header.stateRoot)) {
        const dumpPath = await dumpDrift({
          dataDir: this.dataDir,
          stateStore: journaled,
          block,
          expectedStateRoot: block.header.stateRoot,
          computedStateRoot,
          rebuiltReceipts
        });
        throw new Error(
          `applyExternalBlock: state root mismatch at block ${block.header.number} (computed ${bytesToHex8(computedStateRoot)} vs block ${bytesToHex8(block.header.stateRoot)})` + (dumpPath ? ` \u2014 drift dump written to ${dumpPath}` : "")
        );
      }
      const computedReceiptsRoot = computeReceiptsRoot(rebuiltReceipts);
      if (!bytesEqual5(computedReceiptsRoot, block.header.receiptsRoot)) {
        throw new Error(
          `applyExternalBlock: receipts root mismatch at block ${block.header.number} (computed ${bytesToHex8(computedReceiptsRoot)} vs block ${bytesToHex8(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);
    const externalBlockHash = blake314(BlockHeaderSchema3.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) {
    if (this.consensusEngine.getHeight() !== block.header.number) {
      throw new Error(
        `consensus height drift: engine=${this.consensusEngine.getHeight()} block=${block.header.number}`
      );
    }
    const validatorAddrHex = bytesToHex8(this.validatorKey.address).toLowerCase();
    const inSet = this.consensusEngine.getValidators().some((v) => v.address.toLowerCase() === validatorAddrHex);
    const round = this.consensusEngine.getRound();
    this.consensusEngine.setPhase("prevote");
    if (inSet) {
      const prevote = await this.signVoteSafely({
        kind: "prevote",
        height: block.header.number,
        round,
        blockHash
      });
      if (prevote) {
        const r = this.ingestConsensusVote(prevote);
        if (!r.ok) {
          throw new Error(`prevote ingest failed: ${r.reason}`);
        }
      }
    }
    await this.consensusEngine.waitForQuorum("prevote", blockHash, this.scaleTimeoutForRound(this.voteTimeoutMs));
    if (inSet) {
      const precommit = await this.signVoteSafely({
        kind: "precommit",
        height: block.header.number,
        round,
        blockHash
      });
      if (precommit) {
        const r = this.ingestConsensusVote(precommit);
        if (!r.ok) {
          throw new Error(`precommit ingest failed: ${r.reason}`);
        }
      }
    }
    await this.consensusEngine.waitForQuorum(
      "precommit",
      blockHash,
      this.scaleTimeoutForRound(this.voteTimeoutMs),
      this.quorumGraceMs
    );
    const commit = this.consensusEngine.commitResultFor(blockHash);
    if (!commit.committed) {
      throw new Error(
        `block ${block.header.number}: no quorum after wait \u2014 precommits=${commit.count} stake=${commit.stake}/${commit.threshold}`
      );
    }
  }
  /**
   * 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.
   */
  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) {
    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 = bytesToHex8(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 = SignedTransactionSchema4.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) {
    for (const listener of this.proposalListeners) {
      try {
        await listener(block, round);
      } 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" };
    }
    const accepted = this.mempool.submit(tx);
    if (!accepted) {
      return { accepted: false, reason: "duplicate or mempool full" };
    }
    const txHash3 = blake314(SignedTransactionSchema4.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 = bytesToHex8(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()
    };
  }
  /**
   * 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: bytesToHex8(stateRoot),
      capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
      accountCount: accounts.length,
      accounts: accounts.map(({ address, account }) => ({
        address: bytesToHex8(address),
        balance: account.balance.toString(),
        nonce: account.nonce.toString(),
        codeHash: bytesToHex8(account.codeHash),
        storageRoot: bytesToHex8(account.storageRoot)
      }))
    };
  }
  async getBalance(address) {
    const account = await this.stateStore.getAccount(address);
    return {
      balance: account?.balance ?? 0n,
      nonce: account?.nonce ?? 0n
    };
  }
  /**
   * 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, key) {
    return this.stateStore.getStorageValue(address, key);
  }
  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() {
    const result = await runReadOnlyCall({
      store: this.stateStore,
      vm: this.vm,
      contractAddress: SLASHING_CONTRACT_ADDRESS,
      method: "getTombstones",
      args: [],
      blockNumber: this.latestHeader.number,
      blockTimestamp: this.latestHeader.timestamp,
      chainId: this.chainId
    });
    if (!result.ok) {
      throw new Error(`getTombstones failed: ${result.reason}`);
    }
    return Array.isArray(result.returnValue) ? result.returnValue : [];
  }
  /**
   * 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;
  }
  /**
   * 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 bytesEqual5(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) return false;
  }
  return true;
}
function 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 hexToBytes4(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
} 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.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... 40 hex chars ..."
        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;
      }
      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 tombstoned = v.tombstoned === true || v.slashed === true;
        var statusText = tombstoned ? 'TOMBSTONED' : 'ACTIVE';
        var statusClass = tombstoned ? 'err' : '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 ' + (tombstoned ? 'tx-status err' : 'tx-status ok') + '">' + 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;
    }
    toast('Enter a block number, tx hash (0x + 64 hex), or address (0x + 40 hex).', '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();
    if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) {
      result.className = 'faucet-result error';
      result.textContent = 'Invalid address \u2014 must be 0x followed by 40 hex characters.';
      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/encoding.ts
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 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: bytesToHex9(h.parentHash),
    stateRoot: bytesToHex9(h.stateRoot),
    transactionsRoot: bytesToHex9(h.transactionsRoot),
    receiptsRoot: bytesToHex9(h.receiptsRoot),
    number: h.number.toString(),
    timestamp: h.timestamp.toString(),
    gasLimit: h.gasLimit.toString(),
    gasUsed: h.gasUsed.toString(),
    baseFeePerGas: h.baseFeePerGas.toString(),
    proposer: bytesToHex9(h.proposer),
    lastCommitBitmap: bytesToHex9(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: bytesToHex9(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: bytesToHex9(receipt.txHash),
    blockNumber: receipt.blockNumber.toString(),
    txIndex: receipt.txIndex,
    sender: bytesToHex9(receipt.sender),
    recipient: bytesToHex9(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 bytesToHex9(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
import { blake3 as blake315 } from "@asentum/crypto";
import { BlockHeaderSchema as BlockHeaderSchema4, SignedTransactionSchema as SignedTransactionSchema5 } from "@asentum/types";
function bytesToHex10(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 hexToBytes5(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(hex2) {
  const bytes = hexToBytes5(hex2);
  if (bytes.length !== 20) {
    throw new Error(`expected 20-byte address, got ${bytes.length} bytes`);
  }
  return bytes;
}
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 blake315(BlockHeaderSchema4.serialize(header));
}
function blockToEthJson(block, includeFullTx) {
  const header = block.header;
  const blockHash = computeBlockHash(header);
  const blockHashHex = bytesToHex10(blockHash);
  const blockNumberHex = quantityHex(header.number);
  const transactions = includeFullTx ? block.transactions.map(
    (tx, i) => txToEthJson(tx, {
      blockHashHex,
      blockNumberHex,
      transactionIndex: i
    })
  ) : block.transactions.map((tx) => bytesToHex10(txHash2(tx)));
  const approxSize = BigInt(
    BlockHeaderSchema4.serialize(header).length + block.transactions.reduce(
      (acc, tx) => acc + SignedTransactionSchema5.serialize(tx).length,
      0
    )
  );
  return {
    number: blockNumberHex,
    hash: blockHashHex,
    parentHash: bytesToHex10(header.parentHash),
    // Unused Ethereum fields — stable sentinels so clients don't choke.
    nonce: "0x0000000000000000",
    sha3Uncles: EMPTY_UNCLE_HASH,
    logsBloom: EMPTY_LOGS_BLOOM,
    transactionsRoot: bytesToHex10(header.transactionsRoot),
    stateRoot: bytesToHex10(header.stateRoot),
    receiptsRoot: bytesToHex10(header.receiptsRoot),
    miner: bytesToHex10(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 txHash2(tx) {
  return blake315(SignedTransactionSchema5.serialize(tx));
}
function txToEthJson(tx, ctx) {
  const from = addressFromPubKey(tx.publicKey);
  const hash = txHash2(tx);
  return {
    blockHash: ctx?.blockHashHex ?? null,
    blockNumber: ctx?.blockNumberHex ?? null,
    transactionIndex: ctx ? quantityHex(ctx.transactionIndex) : null,
    from: bytesToHex10(from),
    to: isZeroAddress(tx.body.to) ? null : bytesToHex10(tx.body.to),
    gas: quantityHex(tx.body.gasLimit),
    gasPrice: quantityHex(tx.body.maxFeePerGas),
    maxFeePerGas: quantityHex(tx.body.maxFeePerGas),
    maxPriorityFeePerGas: quantityHex(tx.body.maxPriorityFeePerGas),
    hash: bytesToHex10(hash),
    input: bytesToHex10(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 = bytesToHex10(receipt.txHash);
  const blockNumberHex = quantityHex(receipt.blockNumber);
  const txIndexHex = quantityHex(receipt.txIndex);
  const logEmitter = receipt.recipient;
  return (receipt.events ?? []).map((ev, logIndex) => ({
    address: bytesToHex10(logEmitter),
    topics: [bytesToHex10(blake315(ev.name))],
    data: bytesToHex10(ev.data),
    blockNumber: blockNumberHex,
    transactionHash: txHashHex,
    transactionIndex: txIndexHex,
    blockHash: blockHashHex,
    logIndex: quantityHex(logIndex),
    removed: false
  }));
}
function receiptToEthJson(receipt, blockHashHex) {
  const txHashHex = bytesToHex10(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: bytesToHex10(receipt.sender),
    // For contract creation, Ethereum sets `to: null` and puts the
    // new contract address in `contractAddress`.
    to: isDeploy || isZeroAddress(receipt.recipient) ? null : bytesToHex10(receipt.recipient),
    cumulativeGasUsed: quantityHex(receipt.gasUsed),
    gasUsed: quantityHex(receipt.gasUsed),
    contractAddress: isDeploy ? bytesToHex10(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 blake315(pubkey).slice(0, 20);
}

// 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 = hexToBytes5(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 = hexToBytes5(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 = hexToBytes5(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 = bytesToHex10(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 = hexToBytes5(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 ? bytesToHex10(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 bytesToHex10(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 bytesToHex10(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(bytesToHex10(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 = bytesToHex10(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 toHex = callObj.to;
    const dataHex = callObj.data;
    if (typeof toHex !== "string") {
      throw new JsonRpcError(
        JsonRpcErrorCode.InvalidParams,
        'eth_call: missing "to" address'
      );
    }
    const toBytes = hexToAddress20(toHex);
    if (typeof dataHex !== "string" || dataHex.length === 0 || dataHex === "0x") {
      return "0x";
    }
    let decoded;
    try {
      const bytes = hexToBytes5(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 bytesToHex10(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(key) {
    const now = this.now();
    let bucket = this.buckets.get(key);
    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(key, 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
var RpcServer = class {
  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;
  }
  options;
  server;
  listeningPort = 0;
  ethRpc;
  corsOrigin;
  faucetLimiter;
  /**
   * 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";
  }
  /** 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 = createServer((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 path = url.pathname;
      if (method === "OPTIONS") {
        this.applyCors(res);
        res.statusCode = 204;
        res.end();
        return;
      }
      if (method === "GET" && path === "/") {
        return this.html(res, 200, EXPLORER_HTML);
      }
      if (method === "GET" && path === "/playground") {
        return this.html(res, 200, PLAYGROUND_HTML);
      }
      if (method === "GET" && path.startsWith("/block/") && !path.endsWith("/receipts")) {
        const rest = path.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" && path.startsWith("/address/")) {
        const rest = path.slice("/address/".length);
        if (/^0x[0-9a-fA-F]{40}\/?$/.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" && path.startsWith("/tx/")) {
        const rest = path.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" && (path === "/governance" || path === "/governance/" || path === "/governance/libraries" || path === "/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" && path.startsWith("/governance/") && /^\/governance\/\d+\/?$/.test(path)) {
        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" && path === "/") {
        const body = await readJsonBodyRaw(req);
        return this.json(res, 200, await this.handleJsonRpc(body));
      }
      if (method === "GET" && path === "/api") {
        return this.json(res, 200, this.root());
      }
      if (method === "GET" && path === "/metadata") {
        return this.json(res, 200, this.metadata(req));
      }
      if (method === "GET" && path === "/genesis") {
        return this.json(res, 200, this.genesisSpecResponse());
      }
      if (method === "GET" && path.startsWith("/raw-block/")) {
        const numStr = path.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: bytesToHex9(bytes) });
      }
      if (method === "GET" && path === "/health") {
        return this.json(res, 200, { status: "ok" });
      }
      if (method === "GET" && path === "/ready") {
        return this.readyProbe(res);
      }
      if (method === "GET" && path === "/chain") {
        return this.json(res, 200, this.chainInfo());
      }
      if (method === "GET" && path === "/libp2p-addrs") {
        return this.json(res, 200, this.libp2pAddrs());
      }
      if (method === "GET" && path === "/validators") {
        return this.json(res, 200, await this.validators());
      }
      if (method === "GET" && path === "/participation") {
        return this.json(res, 200, await this.participation(url.searchParams));
      }
      if (method === "GET" && path === "/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" && path === "/governance/proposals") {
        return this.json(res, 200, await this.governanceProposals());
      }
      if (method === "GET" && path.startsWith("/governance/proposal/")) {
        const idStr = path.slice("/governance/proposal/".length);
        return this.json(res, 200, await this.governanceProposal(idStr));
      }
      if (method === "GET" && path === "/governance/libraries") {
        return this.json(res, 200, await this.governanceLibraries());
      }
      if (method === "GET" && path.startsWith("/governance/library/")) {
        const name = path.slice("/governance/library/".length);
        return this.json(res, 200, await this.governanceLibrary(name));
      }
      if (method === "GET" && path.startsWith("/governance/locked/")) {
        const addr = path.slice("/governance/locked/".length);
        return this.json(res, 200, await this.governanceLocked(addr));
      }
      if (method === "GET" && path === "/slashing/tombstones") {
        return this.json(res, 200, await this.tombstones());
      }
      if (method === "GET" && path === "/consensus/state") {
        return this.json(res, 200, this.consensusState());
      }
      if (method === "POST" && path === "/consensus/vote") {
        const body = await readJsonBody(req);
        return this.json(res, 200, await this.submitVote(body));
      }
      if (method === "POST" && path === "/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" && path.startsWith("/balance/")) {
        const addressHex = path.slice("/balance/".length);
        return this.json(res, 200, await this.balance(addressHex));
      }
      if (method === "GET" && path.startsWith("/block-raw/")) {
        const numberStr = path.slice("/block-raw/".length);
        return this.json(res, 200, await this.blockRaw(numberStr));
      }
      if (method === "GET" && path.startsWith("/block/") && path.endsWith("/receipts")) {
        const numberStr = path.slice("/block/".length, -"/receipts".length);
        return this.json(res, 200, await this.blockReceipts(numberStr));
      }
      if (method === "GET" && path.startsWith("/block/")) {
        const numberStr = path.slice("/block/".length);
        return this.json(res, 200, await this.block(numberStr));
      }
      if (method === "GET" && path.startsWith("/receipts/")) {
        const txHashHex = path.slice("/receipts/".length);
        return this.json(res, 200, await this.receiptByHash(txHashHex));
      }
      if (method === "POST" && path === "/tx") {
        const body = await readJsonBody(req);
        return this.json(res, 200, this.submitTx(body));
      }
      if (method === "POST" && path === "/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" && path === "/view") {
        const body = await readJsonBody(req);
        return this.json(res, 200, await this.viewCall(body));
      }
      if (method === "POST" && path === "/dev/faucet") {
        if (this.faucetLimiter) {
          const ip = this.clientIp(req);
          const check = this.faucetLimiter.consume(ip);
          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
            });
          }
        }
        const body = await readJsonBody(req);
        return this.json(res, 200, await this.faucet(body));
      }
      if (method === "POST" && path === "/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 });
    } 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: bytesToHex9(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 = parseHex3(addressHex, 20);
    } catch (err) {
      return { error: "invalid address", message: String(err) };
    }
    const { balance, nonce } = await this.options.node.getBalance(address);
    return {
      address: bytesToHex9(address),
      balance: balance.toString(),
      nonce: nonce.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: bytesToHex9(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: bytesToHex9(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
    };
  }
  async tombstones() {
    try {
      const list = await this.options.node.getTombstones();
      return {
        count: list.length,
        slashingContract: bytesToHex9(this.options.node.getSlashingContractAddress()),
        tombstones: list
      };
    } catch (err) {
      return { error: "failed to query tombstones", message: String(err) };
    }
  }
  async validators() {
    try {
      const list = await this.options.node.getAllValidators();
      return {
        count: list.length,
        stakingContract: bytesToHex9(this.options.node.getStakingContractAddress()),
        validators: list.map((v) => ({
          address: v.address,
          stake: v.stake,
          bondedAt: v.bondedAt,
          status: v.status ?? "active",
          activeAtBlock: v.activeAtBlock,
          pubKeySize: (v.pubKey || "").length
        }))
      };
    } 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. */
  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 committee = validators.map((v) => v.address.toLowerCase());
      const stakeByAddr = new Map(
        validators.map((v) => [v.address.toLowerCase(), v.stake])
      );
      const counts = /* @__PURE__ */ new Map();
      for (const addr of committee) counts.set(addr, { signed: 0, last: null });
      for (let h = actualStart; h <= head; h += 1n) {
        const block = await node.getBlock(h);
        if (!block) continue;
        let signers;
        try {
          signers = decodeLastCommitBitmap(block.header.lastCommitBitmap, committee);
        } catch {
          continue;
        }
        for (const s of signers) {
          const c = counts.get(s);
          if (c) {
            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 ? bytesToHex9(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 = parseHex3(contractHex, 20);
    } 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 toHex = body.to;
    const amountStr = body.amount;
    if (typeof toHex !== "string" || typeof amountStr !== "string") {
      return { accepted: false, reason: "missing to or amount (both should be strings)" };
    }
    let to;
    try {
      to = parseHex3(toHex, 20);
    } 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 result = await this.options.node.faucetTransfer(to, amount);
    return {
      accepted: result.accepted,
      reason: result.reason,
      txHash: result.txHash ? bytesToHex9(result.txHash) : void 0
    };
  }
  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);
  }
};
async function readJsonBody(req) {
  const parsed = await readJsonBodyRaw(req);
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
    return {};
  }
  return parsed;
}
async function readJsonBodyRaw(req) {
  const chunks = [];
  for await (const chunk of req) {
    chunks.push(chunk);
  }
  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
async function main() {
  const dataDir = process.env.ASENTUM_DATA_DIR ?? join4(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 blockTimeMs = Number.parseInt(process.env.ASENTUM_BLOCK_TIME ?? "6000", 10);
  const role = process.env.ASENTUM_ROLE ?? "producer";
  const peerRpcUrl = process.env.ASENTUM_PEER_RPC;
  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("");
  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: ${bytesToHex9(initResult.faucet.address)}`);
  console.log(`[faucet] NOT for real value \u2014 key is deterministic and public`);
  console.log(`[validator] address: ${bytesToHex9(node.getValidatorAddress())} (${node.getValidatorKey().source})`);
  console.log("");
  const voteBroadcaster = new VoteBroadcaster({
    peers: consensusPeers,
    log: (msg) => console.log(msg)
  });
  if (consensusPeers.length > 0) {
    console.log(`[consensus] gossiping votes + proposals to ${consensusPeers.length} peer(s)`);
    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);
      }
    });
  }
  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 network = new NetworkLayer({
    listenPort: p2pPort,
    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);
      }
    }
  });
  await network.start();
  console.log(`[p2p] peer id: ${network.getPeerId()}`);
  for (const addr of network.getMultiaddrs()) {
    console.log(`[p2p] listening on ${addr}`);
  }
  console.log("");
  node.onBlockProduced(async (block) => {
    try {
      const bytes = BlockSchema5.serialize(block);
      await network.publishBlock(bytes);
    } catch (err) {
      console.error("[net] publishBlock failed:", err);
    }
  });
  node.onTransactionSubmitted(async (tx) => {
    try {
      const bytes = SignedTransactionSchema7.serialize(tx);
      await network.publishTransaction(bytes);
    } catch (err) {
      console.error("[net] publishTransaction failed:", err);
    }
  });
  if (peerP2pAddr) {
    try {
      await network.connectToPeer(peerP2pAddr);
      console.log(`[p2p] connected to ${peerP2pAddr}`);
    } catch (err) {
      console.error("[p2p] dial failed:", err);
    }
  }
  node.start();
  if (role === "producer") {
    console.log("[producer] block production timer running");
  } else {
    console.log("[replica] awaiting blocks via HTTP sync loop");
  }
  let httpSyncLoop;
  if (peerRpcUrl) {
    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 = bytesToHex9(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 = bytesToHex9(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