/**
 * Browser full-node runtime: wallet persistence, sync, heartbeat, produce.
 */
import {
  createNewWallet,
  createWalletFromPhrase,
  isValidSeedPhrase,
  runParitySelfCheck,
  bytesToHex,
  sign,
} from "./wallet_crypto.js";
import { canonicalBytes } from "./stable_json.js";
import {
  Protocol,
  Payload,
  nextProducer,
  vpsCycleSetAllowed,
  createSignedTx,
  produceBlock,
  txHash,
  blockHash,
  genesisBlock,
  blockSizeLimit,
  txSizeBytes,
  txPayloadBytes,
  requiredConfirmations,
  confirmationDepth,
  remainingFinalitySeconds,
} from "./protocol.js";
import { AccountState } from "./state.js";
import { validateBlockStructure } from "./block_validator.js";
import {
  verifyReleaseManifest,
  pickBuildAttestation,
  signHeartbeatPayload,
  setReleasePublicKeyHex,
} from "./release_security.js";
import { RELEASE_PUBLIC_KEY_HEX } from "./release_public_key.js";

setReleasePublicKeyHex(RELEASE_PUBLIC_KEY_HEX);

const DEFAULT_COORD =
  (typeof location !== "undefined" && location.port === "9334"
    ? `${location.protocol}//${location.host}`
    : "http://78.47.21.219:9334");

/** A pending action unlocks again if its transaction never gets mined. */
const PENDING_OP_TIMEOUT_MS = 5 * 60 * 1000;

/** Coordinator calls abort rather than hang the caller forever. */
const API_TIMEOUT_MS = 20000;

/** Min gap between “Waiting for my production slot” console lines. */
const SLOT_WAIT_LOG_INTERVAL_MS = 11_000;
/** Min gap between fork-resync console lines during produce retries. */
const FORK_RESYNC_LOG_INTERVAL_MS = 11_000;
/** Min gap between identical catch-up stall reasons. */
const CATCH_UP_STALL_LOG_INTERVAL_MS = 11_000;
/** Min gap between VPS catch-up pulls triggered by produce submit retries. */
const PRODUCE_CATCHUP_INTERVAL_MS = 11_000;

const PENDING_OP_LABELS = {
  "STAKE:SUBSCRIBE": "staking payment",
  "STAKE:ACTIVATE": "staking activate",
  "STAKE:CLAIM": "staking claim",
  "ACTIVE:CLAIM": "activity claim",
  "INVITE:CLAIM": "referral claim",
  "PRODUCER:JOIN": "producer join",
  "PRODUCER:LEAVE": "producer leave",
  "PRODUCER:BOND": "producer bond",
  "PRODUCER:UNBOND": "producer unbond",
  "PRODUCER:CLAIM_BOND": "producer bond claim",
  transfer: "transfer",
};

function wsUrlFromBase(baseUrl) {
  if (baseUrl.startsWith("https://")) return "wss://" + baseUrl.slice(8);
  if (baseUrl.startsWith("http://")) return "ws://" + baseUrl.slice(7);
  return baseUrl.replace(/^http/, "ws");
}

export class WebNode {
  constructor(opts = {}) {
    this.baseUrl = (opts.baseUrl || DEFAULT_COORD).replace(/\/$/, "");
    this.wallet = null;
    this.state = new AccountState();
    this.state.applyGenesis();
    this.blocks = []; // {height, hash, header, transactions}
    this.mempool = [];
    this.height = 0;
    this.tipHash = "0".repeat(64);
    this.tipTimestamp = 0;
    this.tipTimestampUpdatedAtMs = 0;
    this.genesisEpochSeconds = Protocol.GENESIS_WALL_CLOCK_EPOCH_SECONDS;
    this.genesisHash = null;
    this.genesisTimestamp = null;
    this.running = false;
    this.producingEnabled = false;
    this.batteryStandby = false;
    this.initialSyncComplete = false;
    this.syncingBlocks = false;
    this.startProgressActive = false;
    this.startProgressPercent = 0;
    this.startProgressPhase = "";
    this.startProgressDetail = "";
    this._syncStartHeight = 0;
    this._lastActiveLog = -1;
    this._lastProducingLog = null;
    /** Legacy hub mode (unused). Desktop srmd.exe is a normal producing client. */
    this.exeMode = false;
    this.latestCycle = null;
    this.ws = null;
    this.timers = [];
    this._wsReconnectTimer = null;
    this._syncInFlight = null;
    this.listeners = new Set();
    this.consoleLines = [];
    this.history = [];
    /** op -> submit time; blocks a second click until the tx is mined. */
    this.pendingOps = new Map();
    this.lastSubmittedActivityHbNetworkTs = -1;
    /** Desktop EXE: optional { load, saveBlock, clear } */
    this.chainPersistence = opts.chainPersistence || null;
    this.persistChain = opts.persistChain !== false;
    this._restoringChain = false;
    this.clientVersion = String(opts.clientVersion || "");
    this.clientPlatform = String(opts.clientPlatform || "windows");
    this.buildAttestation = "";
    this.releaseManifest = null;
    /** Sticky VPS home (cluster load-balancing); coordinator may be cluster leader. */
    this.stickyHomeUrl = String(opts.stickyHomeUrl || "").replace(/\/$/, "");
    this._clusterPollCount = 0;
    /** Last known cluster peer URLs (for dead-leader recovery). */
    this._clusterPeers = [];
    this._onClusterLeader = null;
    this._producerStoreReady = false;
    this._producerRamReady = false;
    this._lastForkResyncLogMs = 0;
    this._lastProduceCatchUpMs = 0;
    this._lastCatchUpStallLogMs = 0;
    this._lastCatchUpStallKey = "";
  }

  async fetchReleaseManifest() {
    if (RELEASE_PUBLIC_KEY_HEX === "REPLACE_AT_KEYGEN") return null;
    try {
      const manifest = await this.api("/v1/release/manifest");
      const verified = verifyReleaseManifest(manifest);
      if (!verified.ok) {
        this.log("Release manifest rejected: " + verified.error, "err");
        return null;
      }
      this.releaseManifest = verified.manifest;
      this.buildAttestation = pickBuildAttestation(verified.manifest, this.clientPlatform);
      return verified.manifest;
    } catch (e) {
      this.log("Release manifest fetch failed: " + e.message, "err");
      return null;
    }
  }

  chainMeta() {
    return {
      height: this.height,
      tipHash: this.tipHash,
      tipTimestamp: this.tipTimestamp,
      genesisHash: this.genesisHash,
      genesisTimestamp: this.genesisTimestamp,
      genesisEpochSeconds: this.genesisEpochSeconds,
    };
  }

  schedulePersistBlock(height, record) {
    if (!this.chainPersistence || !this.persistChain || this._restoringChain) return;
    const meta = {
      ...this.chainMeta(),
      appliedHeight: this.height,
    };
    let state = null;
    try {
      state = this.state && this.state.exportSnapshot ? this.state.exportSnapshot() : null;
    } catch (_) {
      state = null;
    }
    const p = this.chainPersistence.saveBlock(height, record, meta, state);
    if (p && typeof p.catch === "function") p.catch(() => {});
  }

  async loadPersistedChain(networkGenesisTimestamp) {
    if (!this.chainPersistence) return 0;
    let pack;
    try {
      pack = await this.chainPersistence.load();
    } catch (_) {
      return 0;
    }
    if (!pack || !pack.meta || !Number.isFinite(pack.meta.height) || pack.meta.height <= 0) {
      return 0;
    }
    if (networkGenesisTimestamp != null && pack.meta.genesisTimestamp != null) {
      const networkGenesis = genesisBlock(networkGenesisTimestamp);
      if (pack.meta.genesisHash && pack.meta.genesisHash !== networkGenesis.hash) {
        await this.chainPersistence.clear().catch(() => {});
        return 0;
      }
    }
    this._restoringChain = true;
    try {
      if (pack.meta.genesisTimestamp != null) {
        const genesis = genesisBlock(pack.meta.genesisTimestamp);
        this.genesisHash = genesis.hash;
        this.genesisTimestamp = genesis.header.timestamp;
        this.blocks = [];
        this.mempool = [];
        this.state = new AccountState();
        this.state.applyGenesis();
        this.height = 0;
        this.tipHash = genesis.hash;
        this.tipTimestamp = genesis.header.timestamp;
        this.history = [];
        this.pendingOps.clear();
      }
      if (pack.meta.genesisEpochSeconds) {
        this.genesisEpochSeconds = pack.meta.genesisEpochSeconds;
      }

      const tipH = Number(pack.meta.height) || 0;
      const appliedH = Number(pack.meta.appliedHeight);
      const canFastResume =
        pack.state &&
        Number.isFinite(appliedH) &&
        appliedH === tipH &&
        tipH > 0 &&
        pack.meta.tipHash;

      if (canFastResume && this.state.importSnapshot(pack.state)) {
        this.height = tipH;
        this.tipHash = String(pack.meta.tipHash);
        this.tipTimestamp = Number(pack.meta.tipTimestamp) || this.tipTimestamp;
        this.blocks = Array.isArray(pack.blocks) ? pack.blocks.slice() : [];
        this.blocks.sort((a, b) => (a.height || 0) - (b.height || 0));
        this.log(`Restored local chain at height ${this.height} (pruned window + saved state)`, "ok");
        return tipH;
      }

      if (canFastResume && pack.resumeOnly) {
        // Saved state was unusable (format change) and older block bodies were skipped
        // because resume was expected to cover them, so there is nothing to replay.
        this.log("Saved wallet state could not be restored — syncing again from the network", "warn");
        return 0;
      }

      let applied = 0;
      for (const block of pack.blocks || []) {
        if (this.ingestBlock(block)) applied++;
      }
      if (applied > 0) {
        this.log(`Restored local chain at height ${this.height}`, "ok");
      }
      return applied;
    } finally {
      this._restoringChain = false;
    }
  }

  /** Contract opcode of a transaction, or "transfer" for a plain send. */
  static opOf(tx) {
    const bytes =
      tx.payload instanceof Uint8Array
        ? tx.payload
        : tx.payloadHex
          ? Uint8Array.from((tx.payloadHex.match(/.{1,2}/g) || []).map((h) => parseInt(h, 16)))
          : null;
    return Payload.opcode(bytes) || "transfer";
  }

  /** Drops locks whose transaction never made it into a block, so buttons recover. */
  sweepPendingOps() {
    const now = Date.now();
    for (const [op, at] of this.pendingOps) {
      if (now - at > PENDING_OP_TIMEOUT_MS) this.pendingOps.delete(op);
    }
  }

  isPending(op) {
    this.sweepPendingOps();
    return this.pendingOps.has(op);
  }

  onUpdate(fn) {
    this.listeners.add(fn);
    return () => this.listeners.delete(fn);
  }

  emit() {
    for (const fn of this.listeners) {
      try {
        fn(this.snapshot());
      } catch (_) {}
    }
  }

  log(msg, level = "info") {
    const line = { t: Date.now(), msg: String(msg), level };
    this.consoleLines.push(line);
    if (this.consoleLines.length > 200) this.consoleLines.shift();
    this.emit();
  }

  estimatedNetworkTimestamp() {
    const base = Number(this.tipTimestamp) || 0;
    const at = Number(this.tipTimestampUpdatedAtMs) || 0;
    if (!at) return base;
    return base + Math.max(0, Math.floor((Date.now() - at) / 1000));
  }

  snapshot() {
    const addr = this.wallet ? this.wallet.getAddress() : "";
    const acct = addr ? this.state.accounts.get(addr) : null;
    const inviterAddress =
      (this.wallet && this.wallet.inviterAddress) ||
      (acct && acct.inviterAddress) ||
      null;
    const rs = addr ? this.state.rewardStats(addr, this.estimatedNetworkTimestamp(), this.height) : null;
    this.sweepPendingOps();
    const chainCaughtUp = this.isChainCaughtUp();
    if (rs && !chainCaughtUp) rs.canClaimReferral = false;
    const networkHeight = Number(this.latestCycle?.chainHeight) || 0;
    const pendingKeys = [...this.pendingOps.keys()];
    let syncStatus;
    if (!this.running) {
      syncStatus = "Stopped";
    } else if (this.batteryStandby) {
      syncStatus = "Battery standby — waiting for charger";
    } else if (this.syncingBlocks || networkHeight > this.height) {
      syncStatus = `Syncingâ€¦ height ${this.height} of ${Math.max(networkHeight, this.height)}`;
    } else if (this.pendingOps.size > 0) {
      syncStatus = `Waiting for block â€” ${this.pendingOps.size} pending`;
    } else {
      syncStatus = "In sync";
    }
    const pendingLabels = pendingKeys.map((op) => PENDING_OP_LABELS[op] || op);
    return {
      running: this.running,
      producingEnabled: this.producingEnabled,
      batteryStandby: this.batteryStandby,
      chainCaughtUp,
      address: addr,
      inviterAddress,
      height: this.height,
      networkHeight,
      syncStatus,
      tipHash: this.tipHash,
      tipTimestamp: this.tipTimestamp,
      genesisEpochSeconds: this.genesisEpochSeconds,
      balance: rs ? rs.balance : 0,
      peers: this.latestCycle ? this.latestCycle.activeCount || 0 : 0,
      mempool: this.mempool.length,
      cycle: this.latestCycle,
      rewards: rs,
      consoleLines: this.consoleLines.slice(-80),
      history: this.historyWithStatuses().slice(0, 100),
      hasWallet: !!this.wallet,
      pendingOps: pendingKeys,
      pendingLabels,
      stakedThisMonth: addr ? this.state.hasPaidStakingThisMonth(addr, this.tipTimestamp) : false,
      blockTimeSeconds: Protocol.BLOCK_TIME_START_SECONDS,
      producerAdmissionHeight: Protocol.PRODUCER_ADMISSION_HEIGHT,
      producerAdmitted: addr ? this.state.producerAdmitted(addr, Math.max(this.height, networkHeight)) : true,
      inviteeCount: rs && rs.inviteeCount != null ? rs.inviteeCount : 0,
      canRefer: !!(rs && rs.canRefer),
      producerBonded: !!(rs && rs.producerBonded),
      producerBondLocked: rs && rs.producerBondLocked != null ? rs.producerBondLocked : 0,
      producerUnbondAt: rs && rs.producerUnbondAt != null ? rs.producerUnbondAt : 0,
      startProgressActive: this.startProgressActive,
      startProgressPercent: this.startProgressPercent,
      startProgressPhase: this.startProgressPhase,
      startProgressDetail: this.startProgressDetail,
    };
  }

  /**
   * History rows with live confirmation depth.
   * pending â†’ not mined; confirming X/N â†’ included but below requirement; confirmed â†’ final.
   * Balances still apply at inclusion; this is display/finality policy only.
   */
  historyWithStatuses() {
    return this.history.map((h) => {
      const required = h.requiredConfirmations || Protocol.BUILT_IN_CONTRACT_CONFIRMATIONS;
      if (h.blockHeight == null) {
        return {
          ...h,
          status: "pending",
          confirmations: 0,
          requiredConfirmations: required,
          remainingSeconds: remainingFinalitySeconds(0, required),
          statusLabel: "pending",
        };
      }
      const conf = confirmationDepth(this.height, h.blockHeight);
      const remaining = remainingFinalitySeconds(conf, required);
      if (conf >= required) {
        return {
          ...h,
          status: "confirmed",
          confirmations: conf,
          requiredConfirmations: required,
          remainingSeconds: 0,
          statusLabel: "confirmed",
        };
      }
      return {
        ...h,
        status: "confirming",
        confirmations: conf,
        requiredConfirmations: required,
        remainingSeconds: remaining,
        statusLabel: `confirming ${conf}/${required}`,
      };
    });
  }

  loadWallet() {
    const raw = localStorage.getItem("sarmad_web_wallet");
    if (!raw) return null;
    try {
      const data = JSON.parse(raw);
      this.wallet = createWalletFromPhrase(data.seedPhrase, data.inviter || null);
      return this.wallet;
    } catch {
      return null;
    }
  }

  saveWallet() {
    if (!this.wallet) return;
    localStorage.setItem(
      "sarmad_web_wallet",
      JSON.stringify({
        seedPhrase: this.wallet.seedPhrase,
        inviter: this.wallet.inviterAddress,
      })
    );
  }

  createWallet(inviter) {
    const { wallet, mnemonic } = createNewWallet(inviter || this.pendingInviter());
    this.wallet = wallet;
    this.saveWallet();
    this.emit();
    return mnemonic;
  }

  restoreWallet(phrase, inviter) {
    if (!isValidSeedPhrase(phrase)) throw new Error("Invalid seed phrase");
    this.wallet = createWalletFromPhrase(phrase, inviter || this.pendingInviter());
    this.saveWallet();
    this.emit();
    return this.wallet;
  }

  pendingInviter() {
    try {
      const u = new URL(location.href);
      return u.searchParams.get("inviter");
    } catch {
      return null;
    }
  }

  async api(path, opts = {}) {
    const { timeoutMs, ...fetchOpts } = opts;
    // A coordinator that accepts the socket but never answers would otherwise hang
    // the start loop, the heartbeat and the produce path forever.
    const budget = Number(timeoutMs) > 0 ? Number(timeoutMs) : API_TIMEOUT_MS;
    const controller = typeof AbortController === "function" ? new AbortController() : null;
    const timer = controller ? setTimeout(() => controller.abort(), budget) : null;
    let res;
    try {
      res = await fetch(this.baseUrl + path, {
        cache: "no-store",
        ...fetchOpts,
        ...(controller && !fetchOpts.signal ? { signal: controller.signal } : {}),
        headers: {
          Accept: "application/json",
          ...(opts.body ? { "Content-Type": "application/json" } : {}),
          ...(opts.headers || {}),
        },
      });
    } catch (e) {
      if (e && e.name === "AbortError") throw new Error(`timeout after ${budget}ms`);
      throw e;
    } finally {
      if (timer) clearTimeout(timer);
    }
    if (!res.ok) {
      const text = await res.text().catch(() => "");
      let detail = "";
      try {
        detail = JSON.parse(text).error || "";
      } catch (_) {}
      throw new Error(detail || `HTTP ${res.status}`);
    }
    return res.json();
  }

  signStablePayload(payload) {
    if (!this.wallet) throw new Error("No wallet");
    const idx = this.wallet.currentAddressIndex || 0;
    const kp = this.wallet.keyPairs[idx];
    if (!kp) throw new Error("No key pair");
    return bytesToHex(sign(canonicalBytes(payload), kp.privateKey));
  }

  async listBroadcasts() {
    const addr = this.wallet ? this.wallet.getAddress() : "";
    const q = encodeURIComponent(addr || "");
    const json = await this.api(`/v1/broadcasts?address=${q}`);
    return Array.isArray(json.posts) ? json.posts : [];
  }

  async voteBroadcast(id, optionIndex) {
    if (!this.wallet) throw new Error("No wallet");
    const at = Date.now();
    const payload = {
      op: "vote",
      id: String(id),
      optionIndex: Number(optionIndex),
      at,
    };
    const body = {
      ...payload,
      address: this.wallet.getAddress(),
      publicKey: this.wallet.getPublicKeyHex(),
      signature: this.signStablePayload(payload),
    };
    const json = await this.api(`/v1/broadcasts/${encodeURIComponent(id)}/vote`, {
      method: "POST",
      body: JSON.stringify(body),
    });
    return json.post || json;
  }

  async replyBroadcast(id, text, optionIndex = null) {
    if (!this.wallet) throw new Error("No wallet");
    const at = Date.now();
    const payload = {
      op: "reply",
      id: String(id),
      text: String(text || ""),
      optionIndex: optionIndex == null ? null : Number(optionIndex),
      at,
    };
    const body = {
      ...payload,
      address: this.wallet.getAddress(),
      publicKey: this.wallet.getPublicKeyHex(),
      signature: this.signStablePayload(payload),
    };
    const json = await this.api(`/v1/broadcasts/${encodeURIComponent(id)}/reply`, {
      method: "POST",
      body: JSON.stringify(body),
    });
    return json.post || json;
  }

  connectWs() {
    const wsBase = wsUrlFromBase(this.baseUrl);
    const url = `${wsBase}/v1/ws`;
    try {
      this.ws = new WebSocket(url);
    } catch (e) {
      this.log("WebSocket unavailable: " + e.message, "warn");
      return;
    }
    this.ws.onopen = () => this.log("Relay WebSocket connected", "ok");
    this.ws.onclose = () => {
      this.log("Relay WebSocket closed", "warn");
      if (this.running) {
        if (this._wsReconnectTimer) clearTimeout(this._wsReconnectTimer);
        this._wsReconnectTimer = setTimeout(() => {
          this._wsReconnectTimer = null;
          if (this.running) this.connectWs();
        }, 4000);
      }
    };
    this.ws.onmessage = (ev) => {
      try {
        const msg = JSON.parse(ev.data);
        if (msg.type === "block" && msg.block) {
          // A block we cannot chain onto our tip means we are behind; pull the gap over HTTP.
          if (!this.ingestBlock(msg.block)) this.syncBlocks().catch(() => {});
        }
        if (msg.type === "tx" && msg.tx) this.ingestTx(msg.tx);
        if (msg.type === "hello" && msg.tip && msg.tip.height > this.height) {
          this.syncBlocks().catch(() => {});
        }
        if (msg.type === "reset") {
          this.log("Network reset signal â€” clearing local chain and waiting for new genesis", "warn");
          this.blocks = [];
          this.mempool = [];
          this.state = new AccountState();
          this.height = 0;
          this.tipHash = this.genesisHash || ("0".repeat(64));
          this.tipTimestamp = this.genesisTimestamp || 0;
          this.history = [];
          this.pendingOps.clear();
          if (this.chainPersistence) this.chainPersistence.clear().catch(() => {});
          // Pull status; adoptGenesis no-ops until founder publishes the new timestamp.
          this.api("/v1/status")
            .then((st) => {
              if (st.genesisEpochSeconds) this.genesisEpochSeconds = st.genesisEpochSeconds;
              if (st.genesisTimestamp != null) this.adoptGenesis(st.genesisTimestamp);
            })
            .catch(() => {});
        }
        this.emit();
      } catch (_) {}
    };
  }

  /**
   * Rebuilds the local chain on the network genesis published by the coordinator.
   * Without this the first block we produce would point at a genesis nobody else has.
   */
  adoptGenesis(timestampSeconds) {
    if (timestampSeconds == null) return;
    const genesis = genesisBlock(timestampSeconds);
    if (this.genesisHash === genesis.hash) return;
    this.genesisHash = genesis.hash;
    this.genesisTimestamp = genesis.header.timestamp;
    this.blocks = [];
    this.mempool = [];
    this.state = new AccountState();
    this.state.applyGenesis();
    this.height = 0;
    this.tipHash = genesis.hash;
    this.tipTimestamp = genesis.header.timestamp;
    this.history = [];
    this.pendingOps.clear();
    if (this.chainPersistence) this.chainPersistence.clear().catch(() => {});
    this.log(`Network genesis adopted (timestamp=${genesis.header.timestamp}) â€” local chain wiped`, "ok");
  }

  /**
   * Wire form of a transaction: contract bytes travel as hex only, because a raw
   * Uint8Array turns into an object in JSON and phones would not decode it.
   */
  wireTx(tx) {
    const { payload, ...rest } = tx;
    return {
      ...rest,
      payloadHex: tx.payloadHex || (payload ? bytesToHex(payload) : ""),
    };
  }

  ingestTx(tx) {
    const key = tx.txHash || tx.signature;
    if (this.mempool.some((t) => (t.txHash || t.signature) === key)) return;
    this.mempool.push(tx);
    if (this.mempool.length > Protocol.MAX_MEMPOOL) this.mempool.shift();
  }

  /** Appends a block only when it extends our tip and passes phone-parity validation. */
  ingestBlock(block) {
    const height = Number(block.height);
    if (!Number.isFinite(height)) return false;
    if (height !== this.height + 1) return false;
    if (block.header && block.header.previousBlockHash !== this.tipHash) return false;

    // Structure + tx signatures only. Producer turn is enforced by the VPS on
    // /v1/block-report; applying the *current* cycle here would reject historical sync.
    const tip = { hash: this.tipHash, timestamp: this.tipTimestamp, height: this.height };
    const check = validateBlockStructure(block, tip, height);
    if (!check.valid) {
      console.warn(`[srmd] rejected block ${height}: ${check.message}`);
      return false;
    }

    const record = {
      height,
      hash: check.computedHash || block.hash || blockHash(block.header),
      header: block.header,
      transactions: block.transactions || [],
    };
    this.blocks.push(record);
    this.blocks.sort((a, b) => a.height - b.height);
    this.state.applyBlock(record, height);
    this.height = Math.max(this.height, height);
    this.tipHash = record.hash;
    this.tipTimestamp = record.header.timestamp || 0;
    this.tipTimestampUpdatedAtMs = Date.now();
    for (const tx of record.transactions) {
      this.recordHistory(tx, height, record.hash);
    }
    // miner reward history â€” treated as final at the producing block (1 confirmation).
    if (this.wallet) {
      const miner = this.state.resolveMinerAddress(record);
      if (miner === this.wallet.getAddress()) {
        this.history.unshift({
          direction: "in",
          kind: "block_reward",
          amount: Protocol.MINER_REWARD,
          timestampSeconds: record.header.timestamp,
          blockHeight: height,
          txId: record.hash,
          requiredConfirmations: Protocol.BUILT_IN_CONTRACT_CONFIRMATIONS,
        });
        if (this.history.length > 500) this.history.length = 500;
      }
    }
    this.mempool = this.mempool.filter(
      (t) => !record.transactions.some((x) => x.signature === t.signature)
    );
    // The action is settled once its transaction is in a block, so unlock its button.
    if (this.wallet) {
      const me = this.wallet.getAddress();
      for (const tx of record.transactions) {
        if (tx.fromAddress === me) this.pendingOps.delete(WebNode.opOf(tx));
      }
    }
    this.dropDoomedActivityHeartbeats();
    this.schedulePersistBlock(height, record);
    return true;
  }

  recordHistory(tx, height, blockHashVal) {
    if (!this.wallet) return;
    const me = this.wallet.getAddress();
    const payload = txPayloadBytes(tx);
    const kind = Payload.decode(payload) || "transfer";
    if (kind === Payload.ACTIVE_HEARTBEAT || kind === Payload.COORD_HEARTBEAT) return;
    const isClaim =
      kind === Payload.STAKE_CLAIM ||
      kind === Payload.ACTIVE_CLAIM ||
      kind === Payload.INVITE_CLAIM ||
      kind === Payload.COORD_CLAIM;
    let dir = tx.toAddress === me ? "in" : tx.fromAddress === me ? "out" : null;
    if (!dir) return;
    if (isClaim && tx.fromAddress === me) dir = "in";
    const req = requiredConfirmations(tx.amount, payload);
    const id = tx.txHash || txHash(tx);
    const claimPayout = isClaim ? this.state.getTxClaimPayout(id) || this.state.getTxClaimPayout(tx.signature) : 0;
    const amount = claimPayout > 0 ? claimPayout : tx.amount;
    const existing = this.history.find((h) => h.txId === id);
    if (existing) {
      existing.direction = dir;
      existing.kind = kind;
      existing.amount = amount;
      existing.gasFee = tx.gasFee;
      existing.fromAddress = tx.fromAddress;
      existing.toAddress = tx.toAddress;
      existing.timestampSeconds = Math.floor((tx.timestamp || 0) / 1000);
      existing.blockHeight = height;
      existing.blockHash = blockHashVal;
      existing.requiredConfirmations = req;
      return;
    }
    this.history.unshift({
      direction: dir,
      kind,
      amount,
      gasFee: tx.gasFee,
      fromAddress: tx.fromAddress,
      toAddress: tx.toAddress,
      timestampSeconds: Math.floor((tx.timestamp || 0) / 1000),
      blockHeight: height,
      blockHash: blockHashVal,
      txId: id,
      requiredConfirmations: req,
    });
    if (this.history.length > 500) this.history.length = 500;
  }

  isChainCaughtUp() {
    if (!this.running || !this.initialSyncComplete) return false;
    const networkHeight = Number(this.latestCycle?.chainHeight) || this.height;
    return networkHeight - this.height <= 1;
  }

  /** True when we are only one block behind the coordinator tip (queue grace, not a fork). */
  isBenignTipLag() {
    const net = Number(this.latestCycle?.chainHeight) || 0;
    const local = Number(this.height) || 0;
    return local >= net - 1 && local < net;
  }

  logForkResync(detail) {
    const now = Date.now();
    if (this._lastForkResyncLogMs && now - this._lastForkResyncLogMs < FORK_RESYNC_LOG_INTERVAL_MS) return;
    this._lastForkResyncLogMs = now;
    this.log("I think I am in a fork, resyncing", "warn");
    if (detail) this.log(detail, "warn");
  }

  logCatchUpBeforeSubmit() {
    const now = Date.now();
    if (this._lastTurnCatchUpLogMs && now - this._lastTurnCatchUpLogMs < FORK_RESYNC_LOG_INTERVAL_MS) return;
    this._lastTurnCatchUpLogMs = now;
    this.log("Catching the tip before submitting", "ok");
  }

  /** Why tip catch-up did not advance — rate-limited; does not change produce policy. */
  logCatchUpStall(reason) {
    const now = Date.now();
    const key = String(reason || "").trim();
    if (
      key === this._lastCatchUpStallKey &&
      this._lastCatchUpStallLogMs &&
      now - this._lastCatchUpStallLogMs < CATCH_UP_STALL_LOG_INTERVAL_MS
    ) {
      return;
    }
    this._lastCatchUpStallKey = key;
    this._lastCatchUpStallLogMs = now;
    this.log("Catch-up stalled — " + key, "warn");
  }

  async catchUpOneBlockForSubmit() {
    const now = Date.now();
    if (this._lastProduceCatchUpMs && now - this._lastProduceCatchUpMs < PRODUCE_CATCHUP_INTERVAL_MS) {
      return this.isOnCanonicalTip();
    }
    this._lastProduceCatchUpMs = now;
    await this.prefetchSingleBlock(true);
    if (!this.isOnCanonicalTip()) {
      const before = this.height;
      await this.syncBlocks();
      if (this.height <= before) {
        const net = Number(this.latestCycle?.chainHeight) || this.height;
        this.logCatchUpStall(
          "submit catch-up applied 0 (local=#" + this.height + ", network tip=#" + net + ")"
        );
      }
    }
    return this.isOnCanonicalTip();
  }

  isBenignProduceReject(detail) {
    if (!this.isBenignTipLag()) return false;
    const d = String(detail || "");
    if (/fork/i.test(d)) return false;
    return /tip|previous|parent|height|producer|stale|turn|not open/i.test(d);
  }

  /** Produce only on the live tip â€” never while applying a catch-up batch. */
  isOnCanonicalTip() {
    if (!this.running || !this.initialSyncComplete) return false;
    if (this.syncingBlocks) return false;
    const networkHeight = Number(this.latestCycle?.chainHeight) || this.height;
    return this.height >= networkHeight;
  }

  /** On deck: pull the single missing tip block (no full batch sync). */
  async prefetchOnDeckBlock() {
    const net = Number(this.latestCycle?.chainHeight) || 0;
    const local = Number(this.height) || 0;
    if (local >= net) return;
    if (net - local > 1) {
      await this.syncBlocks();
      return;
    }
    await this.prefetchSingleBlock();
  }

  /** Fetch and ingest exactly one block when one height behind the network tip. */
  async prefetchSingleBlock(logFailures = false) {
    const nextHeight = this.height + 1;
    const tipHashBefore = this.tipHash;
    const localHeight = this.height;
    try {
      const data = await this.api(`/v1/blocks?from=${nextHeight}&limit=1`);
      const parent = (data.blocks || []).find((b) => Number(b.height) === nextHeight);
      if (!parent) {
        if (logFailures) {
          const tip = Number(data.tip?.height) || 0;
          this.logCatchUpStall(
            "coordinator has no block #" +
              nextHeight +
              " yet (relay tip=#" +
              tip +
              ", local=#" +
              localHeight +
              ")"
          );
        }
        return false;
      }
      if (String(parent.header?.previousBlockHash || "") !== tipHashBefore) {
        if (logFailures) {
          this.logCatchUpStall(
            "block #" + nextHeight + " does not build on our tip #" + localHeight + " — syncing"
          );
        }
        return false;
      }
      const ok = this.ingestBlock(parent);
      if (!ok && logFailures) {
        this.logCatchUpStall("could not apply network block #" + nextHeight);
      }
      return ok;
    } catch (_) {
      if (logFailures) {
        this.logCatchUpStall(
          "could not reach coordinator for block #" + nextHeight + " (local=#" + localHeight + ")"
        );
      }
      return false;
    }
  }

  /**
   * At turn open when tip-1: fetch parent N, apply N and build N+1 together.
   * Empty blocks build from the downloaded header while state apply runs (CPU overlap on native;
   * streamlined single-block path in the browser).
   */
  async syncCatchUpParallelForTurn(me) {
    const net = Number(this.latestCycle?.chainHeight) || 0;
    const local = Number(this.height) || 0;
    if (local >= net && this.isOnCanonicalTip()) return false;
    if (net - local > 1) {
      this.logCatchUpStall(
        "behind by " + (net - local) + " (local=#" + local + ", tip=#" + net + ") — full sync"
      );
      const before = this.height;
      await this.syncBlocks();
      if (this.height <= before) {
        this.logCatchUpStall(
          "full sync applied 0 blocks (still at #" + this.height + ", network tip=#" + net + ")"
        );
      }
      return this.isOnCanonicalTip();
    }
    const parentHeight = local + 1;
    const tipHashBefore = this.tipHash;
    let parent;
    try {
      const data = await this.api(`/v1/blocks?from=${parentHeight}&limit=1`);
      parent = (data.blocks || []).find((b) => Number(b.height) === parentHeight);
      if (!parent) {
        const tip = Number(data.tip?.height) || 0;
        this.logCatchUpStall(
          "no tip block #" +
            parentHeight +
            " on relay (relay tip=#" +
            tip +
            ", local=#" +
            local +
            ")"
        );
        return false;
      }
    } catch (_) {
      this.logCatchUpStall("could not reach coordinator for tip block #" + parentHeight);
      return false;
    }
    if (String(parent.header?.previousBlockHash || "") !== tipHashBefore) {
      this.logCatchUpStall(
        "tip block #" + parentHeight + " parent hash mismatch — falling back to full sync"
      );
      await this.syncBlocks();
      return this.isOnCanonicalTip();
    }
    await this.refreshCycle();
    if (!this.latestCycle || this.expectedProducer(this.latestCycle) !== me) {
      this.logCatchUpStall("turn ended while catching tip (no longer expected producer)");
      return false;
    }

    const parentHash = parent.hash || blockHash(parent.header);
    const childHeight = parentHeight + 1;
    const cycle = this.latestCycle;
    const emptyMempool = this.mempool.length === 0;

    if (emptyMempool) {
      const childBlock = produceBlock({
        previousBlockHash: parentHash,
        previousBlockTimestampSeconds: parent.header.timestamp,
        transactions: [],
        producerPublicKey: this.wallet.getPublicKeyHex(),
        skipDepth: 0,
      });
      if (!this.ingestBlock(parent)) {
        this.logCatchUpStall("could not apply tip block #" + parentHeight + " during parallel catch-up");
        await this.syncBlocks();
        return false;
      }
      this._warmBlock = {
        key: `${cycle.cycleId}:${childHeight}:${this.tipHash}`,
        block: childBlock,
        txCount: 0,
        refreshed: false,
      };
      this.emit();
      return true;
    }

    if (!this.ingestBlock(parent)) {
      this.logCatchUpStall("could not apply tip block #" + parentHeight + " during parallel catch-up");
      await this.syncBlocks();
      return false;
    }
    return this.isOnCanonicalTip();
  }

  slotWaitSeconds(cycle) {
    if (!cycle) return 0;
    // Use the coordinator countdown from the GET we just made. Comparing
    // slotDeadlineEpochMs to Date.now() misses the 5s grace whenever Windows
    // time is a few seconds behind the VPS.
    if (cycle.secondsToNextSlot != null && cycle.secondsToNextSlot !== "") {
      return Math.max(0, Number(cycle.secondsToNextSlot) || 0);
    }
    const deadline = Number(cycle.slotDeadlineEpochMs) || 0;
    if (deadline > 0) return Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
    return 0;
  }

  expectedProducer(cycle) {
    const vpsActive = (cycle?.activeNodes || []).map((n) => n.address);
    const fromSchedule = nextProducer({
      cycleId: cycle?.cycleId,
      seedHex: cycle?.seed,
      activeAddresses: vpsActive,
      completedAddresses: cycle?.completed || [],
    });
    const named = String(cycle?.currentProducer || "").trim();
    if (named && fromSchedule && named !== fromSchedule) return fromSchedule;
    if (named) return named;
    return fromSchedule;
  }

  async refreshCycle() {
    try {
      const cycle = await this.api("/v1/cycle");
      if (!cycle) return null;
      this.latestCycle = cycle;
      if (
        Array.isArray(cycle.parkedProducers) &&
        typeof this.state.replaceParkedProducers === "function"
      ) {
        this.state.replaceParkedProducers(cycle.parkedProducers);
      }
      return cycle;
    } catch (_) {
      return this.latestCycle;
    }
  }

  /**
   * Coalesce overlapping syncs. The 5s poll, the WebSocket gap handler and the
   * produce path all call this; two of them mutating the chain at once would
   * ingest the same range twice. Callers still await the work in flight.
   */
  async syncBlocks() {
    if (this.batteryStandby) return;
    if (this._syncInFlight) return this._syncInFlight;
    this._syncInFlight = (async () => {
      try {
        return await this._syncBlocksOnce();
      } finally {
        this._syncInFlight = null;
      }
    })();
    return this._syncInFlight;
  }

  async _syncBlocksOnce() {
    const networkHeight = Number(this.latestCycle?.chainHeight) || 0;
    const catchingUp = networkHeight > this.height;
    if (catchingUp) this.syncingBlocks = true;
    try {
      const data = await this.api(`/v1/blocks?from=${this.height + 1}&limit=200`);
      const ordered = (data.blocks || []).slice().sort((a, b) => a.height - b.height);
      for (const b of ordered) {
        if (!this.ingestBlock(b)) break;
      }
      try {
        const mp = await this.api("/v1/mempool");
        for (const tx of mp.txs || []) this.ingestTx(tx);
      } catch (_) {}
      if (data.tip && data.tip.height != null) {
        if (!this.latestCycle) this.latestCycle = {};
        this.latestCycle.chainHeight = Math.max(
          Number(this.latestCycle.chainHeight) || 0,
          Number(data.tip.height) || 0
        );
      }
    } finally {
      if (catchingUp) this.syncingBlocks = false;
      this.emit();
    }
  }

  reportStartProgress(percent, phase, detail) {
    const p = Math.max(0, Math.min(100, Number(percent) || 0));
    this.startProgressPercent = Math.max(this.startProgressPercent, p);
    this.startProgressPhase = String(phase || "");
    this.startProgressDetail = detail != null ? String(detail) : "";
    this.startProgressActive = true;
    this.emit();
  }

  clearStartProgress() {
    this.startProgressActive = false;
    this.startProgressPercent = 0;
    this.startProgressPhase = "";
    this.startProgressDetail = "";
  }

  /**
   * Pull until local height reaches the coordinator tip. Progress stays 52â€“99.
   */
  async syncUntilCaughtUp() {
    this._syncStartHeight = this.height;
    let lastPercent = 52;
    let idleRounds = 0;
    let warnedStall = false;
    while (this.running) {
      let tip = this.height;
      try {
        const st = await this.api("/v1/status");
        tip = Number(st.chainHeight) || tip;
        if (!this.latestCycle) this.latestCycle = {};
        this.latestCycle.chainHeight = tip;
      } catch (_) {
        tip = Number(this.latestCycle?.chainHeight) || this.height;
      }
      const local = this.height;
      const span = Math.max(1, tip - this._syncStartHeight);
      const done = Math.max(0, local - this._syncStartHeight);
      const frac = Math.max(0, Math.min(1, done / span));
      const raw = local >= tip ? 99 : Math.max(52, Math.min(98, Math.floor(52 + frac * 47)));
      lastPercent = Math.max(lastPercent, raw);
      this.reportStartProgress(lastPercent, "Syncing chain", `${local}/${tip}`);
      if (local >= tip) return;
      const before = this.height;
      await this.syncBlocks();
      if (this.height <= before) {
        // Never give up while the tip is ahead: returning here would let start()
        // mark the node ready and join the produce queue several blocks behind,
        // which the APK never does.
        idleRounds += 1;
        if (idleRounds >= 8 && !warnedStall) {
          warnedStall = true;
          this.log(
            `Still behind the network (local ${local} network ${tip}) — retrying`,
            "warn"
          );
        }
        const backoff = Math.min(3000, 400 * Math.max(1, Math.ceil(idleRounds / 4)));
        await new Promise((r) => setTimeout(r, backoff));
      } else {
        idleRounds = 0;
        warnedStall = false;
      }
    }
  }

  /**
   * Join/stay in produce queue at tip or one block behind (fast-peer tip race).
   * Farther behind â†’ listener until caught up.
   */
  effectiveProducingEnabled() {
    if (this.batteryStandby) return false;
    if (!this.producingEnabled) return false;
    if (!this.latestCycle) return false;
    const tip = Number(this.latestCycle.chainHeight) || 0;
    const local = Number(this.height) || 0;
    return local >= tip - 1 && local <= tip;
  }

  async heartbeat() {
    if (this.batteryStandby) return null;
    if (!this.wallet) return;
    const timestampMs = Date.now();
    const effectiveProducing = this.effectiveProducingEnabled();
    const unsigned = {
      address: this.wallet.getAddress(),
      publicKey: this.wallet.getPublicKeyHex(),
      chainHeight: this.height,
      tipHash: this.tipHash,
      tipTimestamp: this.tipTimestamp,
      producingEnabled: effectiveProducing,
      clientVersion: this.clientVersion,
      platform: this.clientPlatform,
      timestampMs,
      buildAttestation: this.buildAttestation,
    };
    let signature = "";
    if (this.buildAttestation && this.clientVersion) {
      try {
        signature = signHeartbeatPayload(this.wallet, unsigned);
      } catch (e) {
        this.log("Heartbeat sign failed: " + e.message, "err");
      }
    }
    const body = {
      ...unsigned,
      transport: "ws",
      signature,
    };
    const cycle = await this.api("/v1/heartbeat", {
      method: "POST",
      body: JSON.stringify(body),
    });
    this.latestCycle = cycle;
    if (cycle && Array.isArray(cycle.parkedProducers) && typeof this.state.replaceParkedProducers === "function") {
      this.state.replaceParkedProducers(cycle.parkedProducers);
    }
    if (cycle.genesisEpochSeconds) this.genesisEpochSeconds = cycle.genesisEpochSeconds;
    const prevGenesis = this.genesisTimestamp;
    this.adoptGenesis(cycle.genesisTimestamp);
    const activeMiners =
      cycle.activeCount != null ? cycle.activeCount : (cycle.activeNodes || []).length;
    if (activeMiners !== this._lastActiveLog || effectiveProducing !== this._lastProducingLog) {
      this._lastActiveLog = activeMiners;
      this._lastProducingLog = effectiveProducing;
      const role = effectiveProducing
        ? "mining on"
        : this.producingEnabled
          ? "mining on but catching tip â†’ listener until tip"
          : "mining off (listener)";
      this.log(`Coordinator heartbeat OK â€” ${activeMiners} active miner(s), ${role}`, "ok");
    }
    // After a wipe, tip is 0 â€” do not keep mining the old chain.
    if (cycle.chainHeight === 0 && this.height > 0) {
      this.log("Network reset â€” wiping leftover local chain (not pushing)", "warn");
      this.blocks = [];
      this.mempool = [];
      this.state = new AccountState();
      this.state.applyGenesis();
      this.height = 0;
      this.tipHash = this.genesisHash || ("0".repeat(64));
      this.tipTimestamp = this.genesisTimestamp || 0;
      this.history = [];
      this.pendingOps.clear();
      if (this.chainPersistence) this.chainPersistence.clear().catch(() => {});
    } else if (
      cycle.genesisTimestamp != null &&
      prevGenesis != null &&
      Number(cycle.genesisTimestamp) !== Number(prevGenesis)
    ) {
      this.log("Joined wiped network â€” syncing blocks", "ok");
      await this.syncBlocks();
    }
    this.emit();
    await this.maybeFollowClusterLeader();
    return cycle;
  }

  /** Follow cluster leadership (matches Android APK). Always produce against the leader. */
  async maybeFollowClusterLeader() {
    const cur = String(this.baseUrl || "").replace(/\/$/, "");
    const probes = [];
    const pushProbe = (u) => {
      const n = String(u || "").replace(/\/$/, "");
      if (n && !probes.includes(n)) probes.push(n);
    };
    pushProbe(cur);
    pushProbe(this.stickyHomeUrl);
    for (const p of this._clusterPeers || []) pushProbe(p);

    let st = null;
    let probeUsed = "";
    for (const probeUrl of probes) {
      try {
        const res = await fetch(probeUrl + "/v1/status");
        if (!res.ok) continue;
        st = await res.json();
        probeUsed = probeUrl;
        break;
      } catch (_) {}
    }
    if (!st) {
      // Dead leader / unreachable â€” do not keep mining against a corpse.
      if (probes.length > 1) this._onClusterLeader = false;
      return;
    }
    if (Array.isArray(st.clusterPeers) && st.clusterPeers.length) {
      this._clusterPeers = st.clusterPeers.map((u) => String(u || "").replace(/\/$/, "")).filter(Boolean);
    }
    if (!st.clusterEnabled) {
      this._onClusterLeader = true;
      return;
    }
    const leader = String(st.leaderUrl || st.selfUrl || "").replace(/\/$/, "");
    let target = cur;
    if (!st.isLeader && leader) target = leader;
    else if (st.isLeader && st.selfUrl) target = String(st.selfUrl).replace(/\/$/, "");
    else if (st.isLeader) target = probeUsed || cur;
    this._onClusterLeader = !!leader && leader === (target || cur);
    if (!target) {
      this._onClusterLeader = false;
      return;
    }
    if (target === cur) {
      this._onClusterLeader = true;
      return;
    }
    // Confirm the new leader answers before binding.
    try {
      const check = await fetch(target + "/v1/status");
      if (!check.ok) {
        this._onClusterLeader = false;
        return;
      }
    } catch (_) {
      this._onClusterLeader = false;
      return;
    }
    this.baseUrl = target;
    this._onClusterLeader = true;
    this.log("Cluster: switched coordinator to leader " + target, "ok");
    if (this.ws) {
      try {
        this.ws.close();
      } catch (_) {}
      this.ws = null;
    }
    this.connectWs();
  }

  async submitTx(tx) {
    const op = WebNode.opOf(tx);
    tx.txHash = txHash(tx);
    // Lock before any await so a second click cannot slip through and pay twice.
    this.pendingOps.set(op, Date.now());
    this.emit();
    try {
      this.ingestTx(tx);
      this.recordHistory(tx, null, null);
      await this.api("/v1/tx", { method: "POST", body: JSON.stringify({ tx: this.wireTx(tx) }) });
    } catch (e) {
      this.pendingOps.delete(op);
      this.mempool = this.mempool.filter((t) => t.txHash !== tx.txHash);
      this.emit();
      throw e;
    }
    this.log("TX submitted " + tx.txHash.slice(0, 16) + "â€¦", "ok");
    this.emit();
    return tx;
  }

  /** Refuses a duplicate action while the previous one is still unconfirmed. */
  guardPending(op) {
    if (this.isPending(op)) {
      throw new Error("Already submitted â€” waiting for it to be included in a block");
    }
  }

  /**
   * Picks mempool transactions for a new block. Signatures and public keys are large
   * (~15 KB each), so the block size limit must be respected or every other node will
   * reject the block. Repeats of the same contract call by one account are dropped
   * because only the first can ever apply.
   */
  selectTransactions(height) {
    const limit = blockSizeLimit(height);
    const headerAllowance = this.wallet ? this.wallet.getPublicKeyHex().length + 256 : 256;
    const selected = [];
    const seenOps = new Set();
    const window = Protocol.ACTIVE_NODES_CLAIM_WINDOW_HOURS * 3600;
    const tipTs = this.tipTimestamp || 0;
    let size = headerAllowance;
    for (const tx of this.mempool) {
      if (selected.length >= Protocol.MAX_TRANSACTIONS_PER_BLOCK) break;
      const op = WebNode.opOf(tx);
      // Heartbeats from recent miners cannot apply â€” never put them in a block.
      if (op === Payload.ACTIVE_HEARTBEAT || op === Payload.COORD_HEARTBEAT) {
        const lastHb = this.state.activityLastHeartbeatTs.get(tx.fromAddress) || 0;
        const interval = Protocol.ACTIVE_HEARTBEAT_INTERVAL_SECONDS || 1200;
        if (lastHb > 0 && tipTs - lastHb < interval) continue;
      }
      if (op === Payload.ACTIVE_HEARTBEAT) {
        const lastMined = this.state.lastMinedAt.get(tx.fromAddress) || 0;
        if (lastMined > 0 && tipTs - lastMined < window) continue;
      }
      if (op === Payload.INVITE_CLAIM) {
        const claimable = this.state.inviterClaimable.get(tx.fromAddress) || 0;
        if (claimable < Protocol.INVITER_MIN_CLAIM) continue;
      }
      const opKey = `${tx.fromAddress}|${op}`;
      if (seenOps.has(opKey)) continue;
      const txSize = txSizeBytes(tx);
      if (size + txSize > limit) break;
      seenOps.add(opKey);
      selected.push(tx);
      size += txSize;
    }
    return selected;
  }

  async maybeProduce() {
    if (this.batteryStandby) return;
    if (!this.running || !this.producingEnabled || !this.wallet) return;
    if (this._producing) return;
    this._producing = true;
    try {
    if (this.chainPersistence && !this._producerStoreReady) return;
    if (this.chainPersistence && !this._producerRamReady) return;
    await this.maybeFollowClusterLeader();
    if (this._onClusterLeader === false) return;
    await this.refreshCycle();
    if (!this.latestCycle) return;
    // Without the network genesis our block would point at a parent nobody else has.
    if (this.genesisHash == null) {
      if (!this.warnedNoGenesis) {
        this.warnedNoGenesis = true;
        this.log("Waiting for the network genesis before producing", "warn");
      }
      return;
    }
    this.warnedNoGenesis = false;
    const me = this.wallet.getAddress();
    if (this.expectedProducer(this.latestCycle) !== me) {
      this._warmBlock = null;
      // On deck: pull blocks and mempool now so our slot opens on the canonical tip
      // instead of spending it catching up.
      if (String(this.latestCycle.nextProducer || "") === me) await this.prefetchOnDeckBlock();
      return;
    }

    // My turn (slot second 0): parallel apply+build when tip-1, else sync then build.
    if (!this.isOnCanonicalTip()) {
      const now = Date.now();
      if (!this._lastTurnCatchUpLogMs || now - this._lastTurnCatchUpLogMs > 8000) {
        this._lastTurnCatchUpLogMs = now;
        this.log("My turn — syncing and building in parallel", "ok");
      }
      this._warmBlock = null;
      const parallelReady = await this.syncCatchUpParallelForTurn(me);
      if (parallelReady && this._warmBlock) {
        // warm block pre-built during catch-up — continue to slot wait / submit
      } else {
        await this.syncBlocks();
        await this.refreshCycle();
        if (!this.latestCycle || this.expectedProducer(this.latestCycle) !== me) return;
        if (!this.isOnCanonicalTip()) return;
      }
    }
    this._warnedTip = false;
    const height = this.height + 1;
    if (!this.state.producerAdmitted(this.wallet.getAddress(), height)) {
      if (!this._warnedAdmissionBond) {
        this._warnedAdmissionBond = true;
        this.log(
          "Producer bond required from block " +
            Protocol.PRODUCER_ADMISSION_HEIGHT +
            " (lock 3 SRMD; does not buy extra turns)",
          "warn"
        );
      }
      return;
    }
    this._warnedAdmissionBond = false;
    const chainSet = this.state.chainProducerSet(this.height);
    const vpsActive = (this.latestCycle.activeNodes || []).map((n) => n.address);
    if (!vpsCycleSetAllowed(chainSet, vpsActive)) {
      if (!this._warnedQueueSet) {
        this._warnedQueueSet = true;
        this.log("Waiting for chain producer set (VPS cycle still has extra addresses)", "warn");
      }
      return;
    }
    this._warnedQueueSet = false;
    const snapshot = {
      cycleId: this.latestCycle.cycleId,
      seedHex: this.latestCycle.seed,
      activeAddresses: vpsActive,
      completedAddresses: this.latestCycle.completed || [],
    };

    const tipHashBefore = this.tipHash;
    const tipTsBefore = this.tipTimestamp;
    const tipHeightBefore = this.height;

    // Our slot is open, so the tip cannot move under us: build and sign at the START
    // of the slot rather than in its last seconds. ML-DSA signing can outlast that
    // window on a phone, which is what used to burn the turn.
    const warmKey = `${this.latestCycle.cycleId}:${height}:${tipHashBefore}`;
    let warm = this._warmBlock && this._warmBlock.key === warmKey ? this._warmBlock : null;
    const wait = this.slotWaitSeconds(this.latestCycle);
    const stale = warm && !warm.refreshed && warm.txCount !== this.mempool.length;
    if (!warm || (stale && wait <= 10)) {
      const txs = this.selectTransactions(height);
      warm = {
        key: warmKey,
        block: produceBlock({
          previousBlockHash: tipHashBefore,
          previousBlockTimestampSeconds: tipTsBefore,
          transactions: txs,
          producerPublicKey: this.wallet.getPublicKeyHex(),
          skipDepth: 0,
        }),
        txCount: txs.length,
        refreshed: Boolean(warm),
      };
      this._warmBlock = warm;
    }
    // Warm at slot start; submit ASAP once the coordinator-scheduled open has arrived.
    // (After an early previous block, slotOpenedAtMs may still be in the future.)
    const openAt =
      Number(this.latestCycle.slotOpenedAtMs) ||
      (Number(this.latestCycle.slotDeadlineEpochMs) || 0) -
        (Number(this.latestCycle.blockTimeSeconds) || 33) * 1000;
    if (openAt > 0 && Date.now() < openAt) {
      const now = Date.now();
      if (!this._lastSlotWaitLogMs || now - this._lastSlotWaitLogMs > SLOT_WAIT_LOG_INTERVAL_MS) {
        this._lastSlotWaitLogMs = now;
        this.log("Waiting for my production slot", "ok");
      }
      return;
    }
    this._warmBlock = null;
    const block = warm.block;
    // Signing can take seconds — abort if tip moved or we lost the slot.
    if (this.tipHash !== tipHashBefore || this.height !== tipHeightBefore) {
      if (this.isBenignTipLag()) {
        this._warmBlock = warm;
        this.logCatchUpBeforeSubmit();
        await this.catchUpOneBlockForSubmit();
        return;
      }
      this.logForkResync();
      await this.syncBlocks();
      await this.refreshCycle();
      return;
    }
    await this.refreshCycle();
    if (!this.latestCycle || this.expectedProducer(this.latestCycle) !== me) {
      this.log("My turn ended — waiting for the next one", "warn");
      return;
    }
    if (Number(this.latestCycle.chainHeight) > this.height) {
      if (this.isBenignTipLag()) {
        this._warmBlock = warm;
        this.logCatchUpBeforeSubmit();
        await this.catchUpOneBlockForSubmit();
        return;
      }
      this.logForkResync();
      await this.syncBlocks();
      return;
    }
    const record = {
      height,
      hash: block.hash,
      header: block.header,
      transactions: block.transactions,
    };
    // The network decides first: keeping a block the relay refused would leave this
    // tab building on a chain nobody else follows.
    let reply;
    try {
      reply = await this.api("/v1/block-report", {
        method: "POST",
        body: JSON.stringify({
          cycleId: snapshot.cycleId,
          producerAddress: me,
          blockHash: block.hash,
          height,
          tipTimestamp: block.header.timestamp,
          block: { ...record, transactions: record.transactions.map((t) => this.wireTx(t)) },
        }),
      });
    } catch (e) {
      const detail = String((e && e.message) || "");
      if (/slot not open/i.test(detail)) {
        this._warmBlock = warm;
        if (!this._lastSlotWaitLogMs || Date.now() - this._lastSlotWaitLogMs > SLOT_WAIT_LOG_INTERVAL_MS) {
          this._lastSlotWaitLogMs = Date.now();
          this.log("Waiting for my production slot", "ok");
        }
        return;
      }
      if (this.isBenignProduceReject(detail)) {
        this._warmBlock = warm;
        this.logCatchUpBeforeSubmit();
        await this.catchUpOneBlockForSubmit();
        return;
      }
      if (/tip|fork|previous|parent|height|producer|stale|turn/i.test(detail)) {
        this.logForkResync();
      } else {
        this.log(`Block #${height} was not accepted by the network`, "warn");
      }
      await this.syncBlocks();
      await this.refreshCycle();
      return;
    }
    if (reply && reply.blockStored === false) {
      const detail = String(reply.error || "");
      if (/slot not open/i.test(detail)) {
        this._warmBlock = warm;
        if (!this._lastSlotWaitLogMs || Date.now() - this._lastSlotWaitLogMs > SLOT_WAIT_LOG_INTERVAL_MS) {
          this._lastSlotWaitLogMs = Date.now();
          this.log("Waiting for my production slot", "ok");
        }
        return;
      }
      if (this.isBenignProduceReject(detail)) {
        this._warmBlock = warm;
        this.logCatchUpBeforeSubmit();
        await this.catchUpOneBlockForSubmit();
        return;
      }
      if (/tip|fork|previous|parent|height|producer|stale|turn/i.test(detail)) {
        this.logForkResync();
      } else {
        this.log(`Block #${height} was not accepted by the network`, "warn");
      }
      await this.syncBlocks();
      await this.refreshCycle();
      return;
    }
    // Commit locally before claiming success — network accept alone used to log
    // "accepted" even when ingest left this node behind the tip.
    const ingested = this.ingestBlock(record);
    if (!ingested && this.height < height) {
      this.log(
        `Network accepted #${height} but local commit failed — resyncing onto network tip`,
        "warn"
      );
      this.logForkResync();
      await this.syncBlocks();
      await this.refreshCycle();
      return;
    }
    this.log(`Produced block #${height} (accepted by network)`, "block");
    this.dropDoomedActivityHeartbeats();
    await this.heartbeat();
    } finally {
      this._producing = false;
    }
  }

  async start() {
    if (!this.wallet) throw new Error("No wallet");
    if (this.running) return;
    this.running = true;
    this.clearStartProgress();
    this.startProgressActive = true;
    this.log("Node startingâ€¦", "warn");
    this.reportStartProgress(4, "Preparing storage", null);
    const parity = runParitySelfCheck();
    this.log(
      `Crypto parity: SHA1PRNG=${parity.sha1ok} derive=${parity.deriveOk}`,
      parity.sha1ok && parity.deriveOk ? "ok" : "warn"
    );
    try {
      this.reportStartProgress(18, "Finding coordinator", null);
      const st = await this.api("/v1/status");
      if (st.genesisEpochSeconds) this.genesisEpochSeconds = st.genesisEpochSeconds;
      const manifest = await this.fetchReleaseManifest();
      if (RELEASE_PUBLIC_KEY_HEX !== "REPLACE_AT_KEYGEN" && !manifest) {
        throw new Error("Official release verification failed");
      }
      this.reportStartProgress(28, "Loading chain", null);
      await this.loadPersistedChain(st.genesisTimestamp);
      if (this.height <= 0) this.adoptGenesis(st.genesisTimestamp);
      if (st.clusterEnabled && !st.isLeader && st.leaderUrl) {
        await this.maybeFollowClusterLeader();
      }
    } catch (e) {
      this.log("Status fetch failed: " + e.message, "err");
    }
    this.reportStartProgress(52, "Syncing chain", null);
    await this.syncUntilCaughtUp();
    this.reportStartProgress(96, "Starting services", null);
    this.connectWs();
    await this.heartbeat();
    this.initialSyncComplete = true;
    this.reportStartProgress(100, "Ready", null);
    this.emit();
    // Brief Ready state, then hide the ring like the APK does after finalize.
    setTimeout(() => {
      if (!this.running) return;
      this.startProgressActive = false;
      this.emit();
    }, 900);
    this.timers.push(setInterval(() => this.heartbeat().catch(() => {}), 8000));
    this.registerInviterIfNeeded().catch(() => {});
    this.timers.push(
      setInterval(() => this.registerInviterIfNeeded().catch(() => {}), 60000)
    );
    this.registerProducerJoinIfNeeded().catch(() => {});
    this.timers.push(
      setInterval(() => this.registerProducerJoinIfNeeded().catch(() => {}), 15000)
    );
    this.maybeActivityHeartbeat().catch(() => {});
    this.timers.push(
      setInterval(() => this.maybeActivityHeartbeat().catch(() => {}), 60000)
    );
    this.timers.push(setInterval(() => this.maybeProduce().catch(() => {}), 1000));
    this.timers.push(setInterval(() => this.syncBlocks().catch(() => {}), 5000));
    this.timers.push(
      setInterval(() => {
        this.emit();
      }, 1000)
    );
    this.log("Node running â€” keep this tab open to produce", "ok");
    this.emit();
  }

  stop() {
    this.running = false;
    this.producingEnabled = false;
    this.batteryStandby = false;
    this._producerStoreReady = false;
    this._producerRamReady = false;
    if (this.chainPersistence && typeof this.chainPersistence.releaseRam === "function") {
      this.chainPersistence.releaseRam().catch(() => {});
    }
    this.initialSyncComplete = false;
    this.syncingBlocks = false;
    this.clearStartProgress();
    for (const t of this.timers) clearInterval(t);
    this.timers = [];
    if (this._wsReconnectTimer) {
      clearTimeout(this._wsReconnectTimer);
      this._wsReconnectTimer = null;
    }
    if (this.ws) {
      try {
        this.ws.close();
      } catch (_) {}
      this.ws = null;
    }
    this.log("Node stopped", "warn");
    this.emit();
  }

  /**
   * Park the node on battery: stop network churn and release mining RAM, keep state in memory.
   * Produce preference (producingEnabled) is preserved for auto-resume on AC.
   */
  setBatteryStandby(on) {
    const next = !!on;
    if (this.batteryStandby === next) return;
    this.batteryStandby = next;
    if (next) {
      this._producerRamReady = false;
      if (this.chainPersistence && typeof this.chainPersistence.releaseRam === "function") {
        this.chainPersistence.releaseRam().catch(() => {});
      }
      this.log("Battery standby — parked to save power (plug in or Resume to wake)", "warn");
    } else {
      this.log("Left battery standby — network activity resumed", "ok");
    }
    this.emit();
  }

  /** Leave standby and re-arm production RAM if Produce is still on. */
  async wakeFromBatteryStandby() {
    this.setBatteryStandby(false);
    if (this.producingEnabled) {
      await this.setProducing(true);
    } else {
      this.heartbeat().catch(() => {});
      this.emit();
    }
  }

  async setProducing(on) {
    if (on && !this.running) throw new Error("Start the node first");
    if (on && this.chainPersistence && typeof this.chainPersistence.ensureReserve === "function") {
      this.log("Reserving 1 GB producer store (chain + app data)â€¦", "warn");
      const reserved = await this.chainPersistence.ensureReserve();
      if (!reserved || !reserved.ok) {
        this.producingEnabled = false;
        this.emit();
        throw new Error(reserved && reserved.error ? reserved.error : "Need 1 GB free disk to mine");
      }
      this._producerStoreReady = true;
      this.log("Producer store ready â€” 1 GB on disk for this wallet", "ok");
    }
    if (on && this.chainPersistence && typeof this.chainPersistence.ensureRam === "function") {
      this.log("Reserving mining RAM (3â€“13 slabs, 96â€“416 MB)â€¦", "warn");
      const ram = await this.chainPersistence.ensureRam();
      if (!ram || !ram.ok) {
        this.producingEnabled = false;
        this._producerRamReady = false;
        this.emit();
        throw new Error(ram && ram.error ? ram.error : "Need at least 3 RAM slabs (96 MB) to mine");
      }
      this._producerRamReady = true;
      const heldMb = ram.bytes ? Math.floor(ram.bytes / (1024 * 1024)) : 416;
      this.log("Producer RAM ready â€” " + heldMb + " MB held while mining", "ok");
    }
    this.producingEnabled = !!on;
    if (on) {
      this.registerProducerJoinIfNeeded().catch(() => {});
    }
    if (!on) {
      this._producerStoreReady = false;
      this._producerRamReady = false;
      if (this.chainPersistence && typeof this.chainPersistence.releaseRam === "function") {
        this.chainPersistence.releaseRam().catch(() => {});
      }
    }
    this.log(on ? "Production enabled" : "Production disabled", on ? "ok" : "warn");
    this.heartbeat().catch(() => {});
    this.emit();
  }

  // Wallet actions
  toLedgerUnits(amount) {
    if (typeof amount === "string") {
      const parsed = Protocol.parseSrmd(amount);
      if (parsed == null || parsed <= 0) throw new Error("Invalid amount");
      return parsed;
    }
    const n = Number(amount);
    if (!Number.isFinite(n) || n <= 0) throw new Error("Invalid amount");
    return n;
  }

  async send(to, amount) {
    this.guardPending("transfer");
    const units = this.toLedgerUnits(amount);
    const tx = createSignedTx(this.wallet, { toAddress: to, amount: units });
    return this.submitTx(tx);
  }

  async stakeSubscribe(amount) {
    this.guardPending(Payload.STAKE_SUBSCRIBE);
    const value = this.toLedgerUnits(amount);
    if (
      !Number.isFinite(value) ||
      value < Protocol.STAKING_MIN_SUBSCRIPTION ||
      value > Protocol.STAKING_MAX_SUBSCRIPTION
    ) {
      throw new Error(
        `Subscription must be ${Protocol.formatSrmd(Protocol.STAKING_MIN_SUBSCRIPTION)}â€“${Protocol.formatSrmd(Protocol.STAKING_MAX_SUBSCRIPTION)} SRMD`
      );
    }
    // A second payment in the same month can never apply, so refuse it before signing.
    if (this.state.hasPaidStakingThisMonth(this.wallet.getAddress(), this.tipTimestamp)) {
      throw new Error("This month's staking payment is already registered");
    }
    const tx = createSignedTx(this.wallet, {
      toAddress: Protocol.STAKING_ADDRESS,
      amount: value,
      payload: Payload.STAKE_SUBSCRIBE,
      gasFee: 0,
    });
    return this.submitTx(tx);
  }

  async claimStaking() {
    this.guardPending(Payload.STAKE_CLAIM);
    const rs = this.state.rewardStats(this.wallet.getAddress(), this.tipTimestamp, this.height);
    if (!rs.canClaimStaking) {
      throw new Error("No staking reward available to claim yet");
    }
    return this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.STAKING_ADDRESS,
        amount: 0,
        payload: Payload.STAKE_CLAIM,
        gasFee: 0,
      })
    );
  }

  async activateStaking() {
    this.guardPending(Payload.STAKE_ACTIVATE);
    const rs = this.state.rewardStats(this.wallet.getAddress(), this.tipTimestamp, this.height);
    if (!rs.canActivateStaking) {
      throw new Error("Staking is not ready to activate yet");
    }
    return this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.STAKING_ADDRESS,
        amount: 0,
        payload: Payload.STAKE_ACTIVATE,
        gasFee: 0,
      })
    );
  }

  async claimActivity() {
    this.guardPending(this.exeMode ? Payload.COORD_CLAIM : Payload.ACTIVE_CLAIM);
    const rs = this.state.rewardStats(this.wallet.getAddress(), this.tipTimestamp, this.height);
    if (!rs.canClaimActivity) {
      throw new Error("No activity reward available to claim yet");
    }
    return this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.ACTIVE_ADDRESS,
        amount: 0,
        payload: this.exeMode ? Payload.COORD_CLAIM : Payload.ACTIVE_CLAIM,
        gasFee: 0,
      })
    );
  }

  /**
   * Drops ACTIVE/COORD heartbeats that are too soon after the last accepted credit
   * (at most one per 20 network minutes).
   */
  dropDoomedActivityHeartbeats() {
    const interval = Protocol.ACTIVE_HEARTBEAT_INTERVAL_SECONDS || 1200;
    const tipTs = this.tipTimestamp || 0;
    const blockMode =
      this.state.arscBlockEpoch || Protocol.arscBlockEpochActive(this.height);
    const before = this.mempool.length;
    this.mempool = this.mempool.filter((tx) => {
      const op = WebNode.opOf(tx);
      if (op !== Payload.ACTIVE_HEARTBEAT && op !== Payload.COORD_HEARTBEAT) return true;
      if (blockMode) return false;
      const lastHb = this.state.activityLastHeartbeatTs.get(tx.fromAddress) || 0;
      return !(lastHb > 0 && tipTs - lastHb < interval);
    });
    if (this.mempool.length !== before) this.emit();
  }

  /**
   * Legacy ARSC presence heartbeat (pre block-epoch activation).
   * After activation, Produce-on (producerJoined) accrues per block instead.
   */
  async maybeActivityHeartbeat() {
    if (this.exeMode) return; // srmd.exe: COORD heartbeats run in Electron main
    if (!this.running || !this.wallet) return;
    if (this.state.arscBlockEpoch || Protocol.arscBlockEpochActive(this.height)) return;
    const address = this.wallet.getAddress();
    const networkTs = this.estimatedNetworkTimestamp();
    const interval = Protocol.ACTIVE_HEARTBEAT_INTERVAL_SECONDS || 1200;
    const lastOnChain = this.state.activityLastHeartbeatTs.get(address) || 0;
    const last = Math.max(lastOnChain, this.lastSubmittedActivityHbNetworkTs || 0);
    if (last > 0 && networkTs - last < interval) return;
    if (this.isPending(Payload.ACTIVE_HEARTBEAT) || this.isPending(Payload.COORD_HEARTBEAT)) return;
    if (this.mempool.some((tx) => tx.fromAddress === address && WebNode.opOf(tx) === Payload.ACTIVE_HEARTBEAT)) return;
    const huntPayload = await this.activityHeartbeatPayload(address, networkTs, Payload.ACTIVE_HEARTBEAT);
    await this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.ACTIVE_ADDRESS,
        amount: 0,
        payload: huntPayload,
        gasFee: 0,
      })
    );
    this.lastSubmittedActivityHbNetworkTs = networkTs;
  }

  async activityHeartbeatPayload(address, networkTs, baseOp) {
    const op = baseOp || Payload.ACTIVE_HEARTBEAT;
    try {
      const data = await this.api(`/v1/sarfees/hunt-proof?address=${encodeURIComponent(address)}`);
      if (data && data.hunting && data.window != null && data.hmac) {
        const expectedWindow = Protocol.huntWindowId(networkTs);
        if (Number(data.window) === expectedWindow) {
          return `${op}|HUNT|${data.window}|${data.hmac}`;
        }
      }
    } catch (_) {}
    return op;
  }

  proveSarfeesBind() {
    if (!this.wallet) throw new Error("Open a wallet first");
    const address = this.wallet.getAddress();
    const kp = this.wallet.keyPairs[this.wallet.currentAddressIndex || 0];
    const nonce = String(Date.now());
    const msg = new TextEncoder().encode(`SARFEES-BIND|${address}|${nonce}`);
    const sig = bytesToHex(sign(msg, kp.privateKey));
    return `${address}|${nonce}|${sig}|${kp.publicKeyHex}`;
  }

  /**
   * State learns our inviter only from a transaction we signed, and producing a block
   * is not one. Until this lands, every block we mine burns the inviter reward instead
   * of paying it, so publish a free zero-value registration once.
   */
  async registerInviterIfNeeded() {
    if (this.batteryStandby) return;
    const inviter = this.wallet && this.wallet.inviterAddress;
    if (!inviter) return;
    const address = this.wallet.getAddress();
    const account = this.state.accounts.get(address);
    if (account && account.inviterAddress) return;
    if (this.isPending(Payload.INVITE_REGISTER)) return;
    const registerHex = Payload.payloadHex(Payload.encode(Payload.INVITE_REGISTER));
    const queued = this.mempool.some(
      (tx) => tx.fromAddress === address && tx.payloadHex === registerHex
    );
    if (queued) return;
    await this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.INVITER_ADDRESS,
        amount: 0,
        payload: Payload.INVITE_REGISTER,
        gasFee: 0,
      })
    );
    this.log("Registering referral on-chain: " + inviter, "warn");
  }

  async registerProducerJoinIfNeeded() {
    if (this.batteryStandby) return;
    if (!this.wallet) return;
    const address = this.wallet.getAddress();
    const admissionEra = Protocol.producerAdmissionRequired(this.height + 1);
    if (this.producingEnabled) {
      if (admissionEra && !this.state.isProducerBonded(address)) return;
      if (this.state.isProducerJoined(address) && !this.state.isProducerParked(address)) return;
      if (this.isPending(Payload.PRODUCER_JOIN)) return;
      const joinHex = Payload.payloadHex(Payload.encode(Payload.PRODUCER_JOIN));
      const queued = this.mempool.some(
        (tx) => tx.fromAddress === address && tx.payloadHex === joinHex
      );
      if (queued) return;
      await this.submitTx(
        createSignedTx(this.wallet, {
          toAddress: Protocol.PRODUCER_BOND_ADDRESS,
          amount: 0,
          payload: Payload.PRODUCER_JOIN,
          gasFee: 0,
        })
      );
      this.log("Joining the on-chain producer queue", "ok");
      return;
    }
    if (!this.state.isProducerJoined(address)) return;
    if (this.isPending(Payload.PRODUCER_LEAVE)) return;
    const leaveHex = Payload.payloadHex(Payload.encode(Payload.PRODUCER_LEAVE));
    const leaveQueued = this.mempool.some(
      (tx) => tx.fromAddress === address && tx.payloadHex === leaveHex
    );
    if (leaveQueued) return;
    await this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.PRODUCER_BOND_ADDRESS,
        amount: 0,
        payload: Payload.PRODUCER_LEAVE,
        gasFee: 0,
      })
    );
    this.log("Leaving producer queue (Produce off — not eligible for ARSC)", "warn");
  }

  async lockProducerBond() {
    this.guardPending(Payload.PRODUCER_BOND);
    const addr = this.wallet.getAddress();
    if (this.state.isProducerBonded(addr)) {
      throw new Error("Producer bond already locked");
    }
    if (this.state.getBalance(addr) < Protocol.PRODUCER_ADMISSION_BOND) {
      throw new Error("Need 3 SRMD to lock the producer bond");
    }
    await this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.PRODUCER_BOND_ADDRESS,
        amount: Protocol.PRODUCER_ADMISSION_BOND,
        payload: Payload.PRODUCER_BOND,
        gasFee: 0,
      })
    );
    this.log("Locking 3 SRMD producer bond", "ok");
  }

  async unbondProducerBond() {
    this.guardPending(Payload.PRODUCER_UNBOND);
    const addr = this.wallet.getAddress();
    if (!this.state.isProducerBonded(addr)) {
      throw new Error("No active producer bond to unbond");
    }
    await this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.PRODUCER_BOND_ADDRESS,
        amount: 0,
        payload: Payload.PRODUCER_UNBOND,
        gasFee: 0,
      })
    );
    this.log("Unbonding producer lock â€” 3 SRMD claimable after 33 network days", "warn");
  }

  async claimProducerBond() {
    this.guardPending(Payload.PRODUCER_CLAIM_BOND);
    const addr = this.wallet.getAddress();
    const locked = this.state.producerBondLocked.get(addr) || 0;
    const unbondAt = this.state.producerUnbondAt.get(addr) || 0;
    if (locked < Protocol.PRODUCER_ADMISSION_BOND || unbondAt <= 0) {
      throw new Error("Unbond first, then wait 33 network days before claiming");
    }
    if (this.tipTimestamp - unbondAt < Protocol.PRODUCER_ADMISSION_EXIT_SECONDS) {
      throw new Error("Producer bond still in the 33-day exit wait");
    }
    await this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.PRODUCER_BOND_ADDRESS,
        amount: 0,
        payload: Payload.PRODUCER_CLAIM_BOND,
        gasFee: 0,
      })
    );
    this.log("Claiming 3 SRMD producer bond", "ok");
  }

  async claimReferral() {
    if (!this.isChainCaughtUp()) {
      throw new Error("Wait until the node finishes syncing before claiming");
    }
    this.guardPending(Payload.INVITE_CLAIM);
    const rs = this.state.rewardStats(this.wallet.getAddress(), this.tipTimestamp, this.height);
    if (!rs.canClaimReferral) {
      throw new Error(
        `Referral claim needs at least ${Protocol.formatSrmd(Protocol.INVITER_MIN_CLAIM)} SRMD (have ${Protocol.formatSrmd(rs.inviterClaimable)})`
      );
    }
    return this.submitTx(
      createSignedTx(this.wallet, {
        toAddress: Protocol.INVITER_ADDRESS,
        amount: 0,
        payload: Payload.INVITE_CLAIM,
        gasFee: 0,
      })
    );
  }

  /** Projected SSC next-month estimate; optional extraPayment simulates a pending subscribe. */
  estimateStakingNextMonth(extraPayment = 0) {
    const addr = this.wallet && this.wallet.getAddress();
    if (!addr) return 0;
    const extra = Number(extraPayment) || 0;
    let allShares = 0;
    for (const v of this.state.stakingShares.values()) allShares += v;
    const pool = this.state.getContractBalance(Protocol.STAKING_ADDRESS);
    const userShares = this.state.stakingShares.get(addr) || 0;
    return Protocol.estimateStakingNextMonth(pool + extra, allShares + extra, userShares + extra);
  }

  shareReferralLink() {
    const addr = this.wallet.getAddress();
    return `https://sarmad.pro/mining.html?inviter=${encodeURIComponent(addr)}`;
  }
}

export { Protocol };