/* global React */
// Hiro testnet API helpers + read-only polling hooks.
// Loaded as text/babel BEFORE data.jsx so the helpers are available as
// globals everywhere downstream.
//
// Depends on window.StacksTransactions (set by the module bridge in
// index.html). callReadOnly waits/throws gracefully if the lib hasn't
// loaded yet -- the hooks then keep polling until it has.

// Resolve a network cfg object. Callers may pass cfg explicitly; otherwise
// we fall back to window.CFG (kept in sync by setNetworkGlobals in data.jsx)
// or, as a last resort, the default network. Returns the full cfg object
// (id, apiHost, deployer, singleton, singletonName, tokenTemplate, ...).
function resolveCfg(cfg) {
  if (cfg) return cfg;
  if (window.CFG) return window.CFG;
  if (window.NetworkConfig) {
    return window.NetworkConfig.get(window.NETWORK || window.NetworkConfig.DEFAULT);
  }
  // Safety net: pre-bridge load order would only hit this if network.jsx
  // failed to load. Return a minimal shape pointing at testnet so callers
  // do not crash with "cannot read property apiHost of undefined".
  return {
    id: "testnet",
    apiHost: "https://api.testnet.hiro.so",
    deployer: "ST2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9KJJYWE",
    singletonName: "lp-singleton-v6",
    tokenTemplateName: "restricted-token-template-v6",
    deployFeeMicroStx: 500000,
  };
}

// -------------------------------------------------------------------------
// Low-level helpers
// -------------------------------------------------------------------------

// Encode a Clarity arg to the hex string the call-read endpoint expects.
// Accepts:
//   - already-built ClarityValue (`{ type: ... }` shape)
//   - { kind: "principal", value: "ST...[.contract]" } sugar
//   - { kind: "uint", value: 123n | 123 | "123" } sugar
function encodeArg(arg) {
  const ST = window.StacksTransactions;
  if (!ST) throw new Error("StacksTransactions not loaded yet");
  if (arg && typeof arg === "object" && arg.type !== undefined) {
    return ST.cvToHex(arg);
  }
  if (arg && typeof arg === "object" && arg.kind === "principal") {
    return ST.cvToHex(ST.principalCV(arg.value));
  }
  if (arg && typeof arg === "object" && arg.kind === "uint") {
    return ST.cvToHex(ST.uintCV(arg.value));
  }
  throw new Error("encodeArg: unsupported arg shape: " + JSON.stringify(arg));
}

// POST /v2/contracts/call-read/{addr}/{name}/{fn}.
// Returns the parsed Clarity value (via hexToCV + cvToValue) on success.
// Throws on network error or `okay: false` from the node.
async function callReadOnly({ contractAddress, contractName, fnName, fnArgs = [], sender }, cfg) {
  const ST = window.StacksTransactions;
  if (!ST) throw new Error("StacksTransactions not loaded yet");
  const c = resolveCfg(cfg);

  const url = `${c.apiHost}/v2/contracts/call-read/${contractAddress}/${contractName}/${fnName}`;
  const body = {
    sender: sender || contractAddress,
    arguments: fnArgs.map(encodeArg),
  };

  const r = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  if (!r.ok) {
    const text = await r.text().catch(() => "");
    throw new Error(`call-read ${fnName} HTTP ${r.status}: ${text.slice(0, 200)}`);
  }
  const json = await r.json();
  if (!json.okay) {
    throw new Error(`call-read ${fnName} clarity error: ${json.cause || "unknown"}`);
  }
  // result is "0x..." hex of a serialized ClarityValue.
  const cv = ST.hexToCV(json.result);
  return {
    cv,
    value: unwrapCv(ST.cvToValue(cv, true)),  // recursive plain-JS form
    raw: json.result,
  };
}

// Hiro health probe. Returns { ok, latencyMs, height, error? }.
async function getApiHealth(cfg) {
  const c = resolveCfg(cfg);
  const t0 = performance.now();
  try {
    const r = await fetch(`${c.apiHost}/v2/info`, { cache: "no-store" });
    const latencyMs = Math.round(performance.now() - t0);
    if (!r.ok) return { ok: false, latencyMs, error: `HTTP ${r.status}` };
    const json = await r.json();
    return {
      ok: true,
      latencyMs,
      height: json.stacks_tip_height,
      burnHeight: json.burn_block_height,
    };
  } catch (e) {
    return { ok: false, latencyMs: Math.round(performance.now() - t0), error: String(e) };
  }
}

// Build a ClarityValue from the encodeArg sugar. Used for write calls where
// Connect 8.x serializes ClarityValues itself (we don't pre-hex like reads).
//
// Supported sugar shapes:
//   { kind: "principal",     value: "ST..." | "ST...contract" }  // auto-detect via dot
//   { kind: "uint",          value: number | bigint | string }
//   { kind: "string-ascii",  value: string }
//   { kind: "string-utf8",   value: string }
//   { kind: "optional-utf8", value: string | null | undefined }  // none if nil-ish
function buildCV(arg) {
  const ST = window.StacksTransactions;
  if (!ST) throw new Error("StacksTransactions not loaded yet");
  if (arg && typeof arg === "object" && arg.type !== undefined) return arg;
  if (arg && typeof arg === "object") {
    switch (arg.kind) {
      case "principal":     return ST.principalCV(arg.value);
      case "uint":          return ST.uintCV(arg.value);
      case "string-ascii":  return ST.stringAsciiCV(arg.value);
      case "string-utf8":   return ST.stringUtf8CV(arg.value);
      case "optional-utf8":
        return (arg.value == null || arg.value === "")
          ? ST.noneCV()
          : ST.someCV(ST.stringUtf8CV(arg.value));
    }
  }
  throw new Error("buildCV: unsupported arg shape: " + JSON.stringify(arg));
}

// Sign + broadcast a contract call via Connect 8.x's `request` (SIP-030).
// Returns { txid } on user signature; throws on cancel / wallet error.
//
// Connect 8.x dispatches stx_callContract internally:
//   - Joins contract = `${contractAddress}.${contractName}` for the wallet.
//   - Serializes ClarityValue functionArgs to hex itself.
//   - Surfaces the wallet's response (Leather + Xverse both return { txid }).
//
// We default to postConditionMode "deny" with an empty PC array per spec —
// the wallet will warn the user and the tx will likely abort_by_post_condition
// at confirmation time. That's fine for the cabling demo (we want to see the
// pending → error toast cycle). Pass a populated `postConditions` array to
// upgrade to a "real" tx that reaches the contract logic.
// Strip "0x" prefix if present. Leather accepts prefixed hex on functionArgs
// (cleanHex strips it internally), but rejects prefixed hex on postConditions
// (hexToBytes from @noble/hashes throws on odd-length / non-hex chars). For
// consistency we strip on both.
function stripHexPrefix(s) {
  return typeof s === "string" && s.startsWith("0x") ? s.slice(2) : s;
}

// Detect provider by name (returns identifier, not the reference). Re-read
// `window.<provider>` per call via getFreshProvider — caching can hold a
// reference into a stale inpage bridge after MV3 service-worker eviction
// (~30s idle) or extension reload.
function detectProviderName() {
  if (window.LeatherProvider) return "Leather";
  if (window.XverseProviders?.StacksProvider) return "Xverse";
  if (window.AsignaProvider) return "Asigna";
  return null;
}

function getFreshProvider() {
  if (window.LeatherProvider) return window.LeatherProvider;
  if (window.XverseProviders?.StacksProvider) return window.XverseProviders.StacksProvider;
  if (window.AsignaProvider) return window.AsignaProvider;
  return null;
}

// CANONICAL 2026 WRITE PATH — Connect 8's request("stx_callContract").
// Mirrors the pattern shipped by every production Stacks dApp surveyed
// (obycode/stackflow, sigle, Stacks-Wars, citycoins2, stacks-guestbook).
// Source-of-truth example: obycode/stackflow docs/app.js callContract().
//
// Key differences from previous (broken) attempt:
//  - Use Connect 8's request() (not LeatherProvider.request directly, not
//    Connect 7 openContractCall). Connect 8's compat layer auto-serializes
//    CVs and routes through the persisted provider correctly.
//  - functionArgs are ClarityValue OBJECTS (not hex strings). Connect 8
//    serializes internally — this is the prod pattern, NOT what we did
//    before. The prior "we must hex-encode for Leather" lesson is wrong
//    for Connect 8's request path.
//  - network is a STRING ("testnet"|"mainnet"). Never an object.
//  - postConditionMode is a STRING ("deny"|"allow"). Never numeric.
//  - No anchorMode, no appDetails, no sponsored, no onFinish/onCancel
//    callbacks — Promise-based, wallet handles UX.
// Resolve Xverse's real provider (BitcoinProvider is the canonical entry on
// Xverse; StacksProvider can be hijacked by Leather). Mirror of getXverseProvider
// in main.jsx — duplicated here so api.jsx stays self-contained.
function _getXverseProvider() {
  const xv = window.XverseProviders;
  if (!xv) return null;
  if (xv.BitcoinProvider && typeof xv.BitcoinProvider.request === "function") {
    return xv.BitcoinProvider;
  }
  if (xv.StacksProvider && typeof xv.StacksProvider.request === "function") {
    return xv.StacksProvider;
  }
  return null;
}

async function signAndBroadcast({
  contractAddress,
  contractName,
  fnName,
  fnArgs = [],
  postConditions = [],
  postConditionMode = "deny",
  network,
  cfg,
}) {
  const C8 = window.StacksConnect;
  if (!C8?.request) {
    throw new Error("@stacks/connect@8 not loaded -- check index.html bridge");
  }
  // Default `network` from cfg.id (testnet/mainnet) if caller did not set it.
  if (!network) network = resolveCfg(cfg).id;

  const ST = window.StacksTransactions;
  const cvArgs = fnArgs.map(buildCV);

  // Prefer Xverse's native stx_callContract when available. It bypasses the
  // dapp-built tx hex path (and the stx_signTransaction "Network mismatch"
  // origin-check that hits us on mainnet) -- Xverse builds + signs the tx
  // itself using its own active network. Schema per sats-connect-core:
  //   { contract, functionName, functionArgs[hex], postConditions, postConditionMode }
  // No `network` field; the wallet uses its global active network.
  const xverseProvider = _getXverseProvider();
  if (xverseProvider) {
    if (!ST?.cvToHex) {
      throw new Error("@stacks/transactions not loaded -- cannot hex-encode args");
    }
    const hexArgs = cvArgs.map(cv => ST.cvToHex(cv));
    const xParams = {
      contract:     `${contractAddress}.${contractName}`,
      functionName: fnName,
      functionArgs: hexArgs,
      postConditions,
      postConditionMode,
    };
    console.log("[WALLET] stx_callContract (Xverse direct) REQUEST:", xParams);
    let raw;
    try {
      raw = await xverseProvider.request("stx_callContract", xParams);
      console.log("[WALLET] stx_callContract (Xverse direct) RAW:", raw);
    } catch (e) {
      console.error("[WALLET] stx_callContract THREW:", e);
      if (e?.error?.code === 4001 || e?.code === 4001) throw new Error("User rejected");
      throw new Error(`Wallet error: ${e?.error?.message || e?.message || JSON.stringify(e)}`);
    }
    if (raw && typeof raw === "object" && "error" in raw && raw.error) {
      throw new Error(`Xverse callContract error ${raw.error.code ?? ""}: ${raw.error.message || JSON.stringify(raw.error)}`);
    }
    const result = raw?.result || raw;
    const txid = result?.txid;
    if (!txid) throw new Error("No txid in Xverse response: " + JSON.stringify(raw));
    const normalizedTxid = txid.startsWith("0x") ? txid : `0x${txid}`;
    return { txid: normalizedTxid, raw };
  }

  // Fallback: Connect 8 path for Leather and other wallets.
  let response;
  try {
    response = await C8.request("stx_callContract", {
      contract: `${contractAddress}.${contractName}`,
      functionName: fnName,
      functionArgs: cvArgs,
      network,
      postConditionMode,
      postConditions,
    });
  } catch (e) {
    if (e?.error?.code === 4001) throw new Error("User rejected");
    if (e?.error) throw new Error(`Wallet ${e.error.code}: ${e.error.message}`);
    throw new Error(`Wallet error: ${e?.message || JSON.stringify(e)}`);
  }

  // Response shape (production observation): { txid: "0x..." } or { txid: "..." }
  // Some Leather builds drop the 0x prefix occasionally — normalize.
  const txid = response?.txid || response?.result?.txid;
  if (!txid) {
    throw new Error("No txid in response: " + JSON.stringify(response));
  }
  const normalizedTxid = txid.startsWith("0x") ? txid : `0x${txid}`;
  return { txid: normalizedTxid, raw: response };
}

// -------------------------------------------------------------------------
// Token deploy + create-pool helpers (Connect 8 / SIP-030 stx_deployContract)
// -------------------------------------------------------------------------

// Fetch the deployed template's source bytes from Hiro. We re-deploy these
// bytes verbatim under the user's chosen contract name. Zero rewrite logic
// in the browser -- no risk of drifting from APPROVED_TOKEN_HASH.
//
// The template deployer and contract name are network-scoped (per cfg).
// On testnet: ST2ABWV7...restricted-token-template-v6.
// On mainnet: SP2GF0C5...restricted-token-template-v6-mn-demo.
async function fetchTokenTemplateSource(cfg) {
  const c = resolveCfg(cfg);
  const url = `${c.apiHost}/extended/v1/contract/${c.deployer}.${c.tokenTemplateName}`;
  const r = await fetch(url, { cache: "no-store" });
  if (!r.ok) {
    const text = await r.text().catch(() => "");
    throw new Error(`fetchTokenTemplateSource HTTP ${r.status}: ${text.slice(0, 200)}`);
  }
  const j = await r.json();
  if (!j.source_code) {
    throw new Error("fetchTokenTemplateSource: response missing source_code");
  }
  return j.source_code;
}

// Sign + broadcast a contract publish via Connect 8 (SIP-030 stx_deployContract).
// Mirror of signAndBroadcast() but for deploys: same Promise-based shape,
// same error normalization, returns { txid, raw }.
async function signAndDeployContract({
  name,
  source,
  network,
  cfg,
  // Clarity 4: token template uses contract-hash? (Clarity-4-only builtin).
  // Publishing as v3 aborts at runtime with "use of unresolved function".
  clarityVersion = 4,
}) {
  const C8 = window.StacksConnect;
  if (!C8?.request) {
    throw new Error("@stacks/connect@8 not loaded -- check index.html bridge");
  }
  if (!network) network = resolveCfg(cfg).id;
  let response;
  try {
    response = await C8.request("stx_deployContract", {
      name,
      clarityCode: source,
      network,
      clarityVersion,
    });
  } catch (e) {
    if (e?.error?.code === 4001) throw new Error("User rejected");
    if (e?.error) throw new Error(`Wallet ${e.error.code}: ${e.error.message}`);
    throw new Error(`Wallet error: ${e?.message || JSON.stringify(e)}`);
  }
  const txid = response?.txid || response?.result?.txid;
  if (!txid) throw new Error("No txid in response: " + JSON.stringify(response));
  const normalizedTxid = txid.startsWith("0x") ? txid : `0x${txid}`;
  return { txid: normalizedTxid, raw: response };
}

// Minimal hex <-> bytes helpers. @stacks/common (which exports the canonical
// versions) is not loaded in the page bridge, and @stacks/transactions@7
// stopped re-exporting them. Inline both to keep buildAndSignDeployContract
// self-contained.
function bytesToHexLocal(bytes) {
  let hex = "";
  for (let i = 0; i < bytes.length; i++) {
    hex += bytes[i].toString(16).padStart(2, "0");
  }
  return hex;
}
function hexToBytesLocal(hex) {
  const clean = hex.replace(/^0x/, "");
  if (clean.length % 2 !== 0) throw new Error("hex string has odd length");
  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;
}

// Path A workaround for the Connect-8 / wallet-builder bug. Connect 8's
// stx_deployContract path corrupts `clarityCode`: the source bytes end up
// double-JSON-stringified, wrapped as {json: source}, base64'd, and placed
// in the on-chain `name` field. Confirmed on testnet across 13+ failed
// deploys from both Xverse and Leather paths.
//
// This function side-steps that by building the deploy tx itself in JS using
// @stacks/transactions (same code path as scripts/deploy-testnet.mjs, which
// reliably produces cv=4 deploys on testnet) and asks the wallet only to
// sign the pre-built hex. We then broadcast the signed bytes directly to the
// Stacks node, bypassing Connect 8 entirely from byte construction onward.
//
// Known limitation: Leather's bundled @stacks/transactions doesn't recognize
// ClarityVersion: 4 in its deserializer, so its sign modal will still crash
// when it tries to display the tx. That's an extension-side bug we cannot
// fix from the front; Xverse is expected to accept this path.
async function buildAndSignDeployContract({
  name,
  source,
  network,
  cfg,
  clarityVersion = 4,
  feeMicroStx,
}) {
  const T  = window.StacksTransactions;
  const N  = window.StacksNetwork;
  const C8 = window.StacksConnect;
  if (!T?.makeUnsignedContractDeploy) {
    throw new Error("@stacks/transactions@7 (with makeUnsignedContractDeploy) not loaded");
  }
  const c = resolveCfg(cfg);
  if (!network) network = c.id;
  if (feeMicroStx == null) feeMicroStx = c.deployFeeMicroStx;
  const netConst = network === "mainnet" ? N.STACKS_MAINNET : N.STACKS_TESTNET;
  if (!netConst) {
    throw new Error("@stacks/network@7 not loaded -- check index.html");
  }
  if (!C8?.request) {
    throw new Error("@stacks/connect@8 not loaded -- check index.html bridge");
  }

  // Pre-flight balance check. Xverse's stx_deployContract returns a txid
  // optimistically without surfacing a "broadcast rejected" error when the
  // node refuses the tx (e.g. insufficient balance). Without this gate the
  // user would see a "success" toast with a txid that never lands on chain.
  // Cheap to verify here -- one HTTP GET to the same nonce endpoint we'd
  // hit anyway in the fallback path.
  // Need address before balance check; resolve from cached session.
  let _preflightAddr = null;
  try {
    const raw = localStorage.getItem("stacks-strategy:wallet");
    if (raw) {
      const parsed = JSON.parse(raw);
      const addrs  = Array.isArray(parsed?.addresses) ? parsed.addresses : [];
      const stx    = addrs.find(a => /^(ST|SP)[A-Z0-9]+$/.test(a?.address || ""));
      _preflightAddr = stx?.address || null;
    }
  } catch (_) {}
  if (_preflightAddr) {
    try {
      const bR = await fetch(`${c.apiHost}/v2/accounts/${_preflightAddr}?proof=0`, { cache: "no-store" });
      if (bR.ok) {
        const bJ = await bR.json();
        // balance is hex-encoded uSTX (0x prefix + up to 32 hex chars).
        const balanceHex = String(bJ.balance || "0x0").replace(/^0x/, "") || "0";
        const balanceUStx = BigInt("0x" + balanceHex);
        const needed = BigInt(feeMicroStx);
        console.log("[WALLET] balance pre-check:", {
          address: _preflightAddr,
          balanceUStx: balanceUStx.toString(),
          balanceStx: (Number(balanceUStx) / 1_000_000).toFixed(6),
          neededUStx: needed.toString(),
          neededStx: (Number(needed) / 1_000_000).toFixed(6),
        });
        if (balanceUStx < needed) {
          throw new Error(
            `Insufficient STX balance on ${network}: have ${(Number(balanceUStx)/1_000_000).toFixed(6)} STX, ` +
            `need at least ${(Number(needed)/1_000_000).toFixed(6)} STX for the deploy fee ` +
            `(plus ~0.5 STX for create-pool). Fund ${_preflightAddr} and retry.`
          );
        }
      }
    } catch (e) {
      // Re-throw insufficient-balance errors (we crafted them above); swallow
      // network errors so a transient Hiro hiccup doesn't block the deploy.
      if (e?.message?.startsWith("Insufficient STX balance")) throw e;
      console.warn("[WALLET] balance pre-check skipped (Hiro error):", e?.message || e);
    }
  }

  // ============================================================
  // Deploy path notes -- READ BEFORE CHANGING
  // ============================================================
  // We INTENTIONALLY do NOT use Xverse's `stx_deployContract` here, even
  // though it would bypass the Network mismatch issue on stx_signTransaction.
  //
  // Reason: Xverse's stx_deployContract handler wraps `clarityCode` in a
  // jsontokens JWT (`{"typ":"JWT","alg":"none"}.<payload>`) where the
  // payload is the source double-JSON-stringified as `{"json":"<source>"}`,
  // then sends THAT as the contract body on-chain. The chain receives a
  // base64 JWT string instead of Clarity source, fails to compile, and the
  // tx aborts with (err none). Confirmed on mainnet:
  //   txid 0x90b15e63ec6904cee1b2c97b33a348f8fb73f11999cea7a5ea339287f222a0e5
  // Same bug regardless of whether we route through Connect 8 or call
  // `XverseProviders.BitcoinProvider.request("stx_deployContract", ...)`
  // directly -- it's inside the Xverse extension's legacy compat layer for
  // pre-SIP-030 openContractDeploy, not in the SDK.
  //
  // The build-locally + stx_signTransaction path BELOW produces correct
  // Clarity bytes on chain. Its known wart is that Xverse may reject with
  // "Network mismatch" on mainnet due to a stale per-origin session record
  // (separate Xverse bug). The user-side workaround is documented in the
  // README: clear Chrome site data for the dapp's origin once, then
  // reconnect with the app on mainnet. After that the binding is fresh and
  // signing works for the lifetime of the new session.
  // ============================================================
  // Build locally + stx_signTransaction (works for Xverse + Leather)
  // ============================================================

  // 0. Pre-flight: refuse Leather for cv>=4. Its bundled @stacks/transactions
  // can't deserialize the ClarityVersion enum for v4+, so its sign modal
  // crashes ("Cannot recognize ClarityVersion: 4") before the user can even
  // see what they're signing. Abort here with an actionable message so the
  // user can switch wallet for this single step. Will become a no-op once
  // Leather ships an extension with @stacks/transactions@7+.
  const providerId =
    typeof C8.getSelectedProviderId === "function"
      ? C8.getSelectedProviderId()
      : null;
  if (providerId === "LeatherProvider" && clarityVersion >= 4) {
    throw new Error(`Leather does not support Clarity ${clarityVersion} deploys. Use Xverse.`);
  }

  // 1. Resolve user's STX pubkey + address.
  // Prefer the cached session from useRealWallet (persisted by Connect 8 in
  // localStorage at connect() time with both address + publicKey). Skipping
  // the live stx_getAddresses round-trip eliminates a wallet-side hang we
  // observed on Xverse mainnet: the getAddresses request never resolves
  // and never rejects, leaving the deploy button locked on
  // "Confirm deploy in wallet...". The cache is safe to trust here because:
  //   * assertWalletReady already verified wallet+app on the same network
  //   * connect() is the only writer to the cache and binds at session start
  //   * a network switch in the app would have auto-disconnected and wiped it
  // Live getAddresses only runs as a fallback if the cache is somehow empty
  // (mock wallet, cleared storage mid-flow). Time-bounded by Promise.race.
  let userAddress, userPubKey;
  try {
    const raw = localStorage.getItem("stacks-strategy:wallet");
    if (raw) {
      const parsed = JSON.parse(raw);
      const addrs = Array.isArray(parsed?.addresses) ? parsed.addresses : [];
      const stx =
        addrs.find(a => a?.symbol === "STX" || a?.symbol === "stx" || a?.symbol === "stacks") ||
        addrs.find(a => typeof a?.address === "string" && /^(ST|SP)[A-Z0-9]+$/.test(a.address));
      if (stx?.address && stx?.publicKey) {
        userAddress = stx.address;
        userPubKey  = stx.publicKey;
      }
    }
  } catch (_) { /* fall through to live probe */ }

  if (!userAddress || !userPubKey) {
    let addrResp;
    try {
      const TIMEOUT_MS = 8000;
      const req = C8.request("stx_getAddresses");
      const timeout = new Promise((_, rej) =>
        setTimeout(() => rej(new Error("stx_getAddresses timeout (wallet not responding)")), TIMEOUT_MS));
      addrResp = await Promise.race([req, timeout]);
    } catch (e) {
      if (e?.error?.code === 4001) throw new Error("User rejected getAddresses");
      if (e?.error) throw new Error(`Wallet getAddresses error: ${e.error.code}: ${e.error.message}`);
      throw new Error(`Wallet getAddresses error: ${e?.message || JSON.stringify(e)}`);
    }
    const addrs = addrResp?.addresses || addrResp?.result?.addresses || [];
    const stxEntry =
      addrs.find(a => a.symbol === "STX") ||
      addrs.find(a => typeof a?.address === "string" && /^S[TPMN]/.test(a.address));
    if (!stxEntry?.publicKey || !stxEntry?.address) {
      throw new Error("No STX address/pubkey in wallet response: " + JSON.stringify(addrResp));
    }
    userAddress = stxEntry.address;
    userPubKey  = stxEntry.publicKey;
  }

  // 2. Fetch current nonce from Hiro.
  const nonceR = await fetch(`${c.apiHost}/v2/accounts/${userAddress}?proof=0`, { cache: "no-store" });
  if (!nonceR.ok) {
    throw new Error(`Failed to fetch nonce: HTTP ${nonceR.status}`);
  }
  const nonceJ = await nonceR.json();
  const nonce  = BigInt(nonceJ.nonce);

  // 3. Map Clarity version to the lib's enum (with fallback to integer).
  const cvByInt = {
    2: T.ClarityVersion?.Clarity2 ?? 2,
    3: T.ClarityVersion?.Clarity3 ?? 3,
    4: T.ClarityVersion?.Clarity4 ?? 4,
  };
  const cv = cvByInt[clarityVersion];
  if (cv === undefined) throw new Error(`Unsupported clarityVersion: ${clarityVersion}`);

  // Network constants live in @stacks/network@7, NOT @stacks/transactions
  // (the transactions package no longer re-exports them in v7). Pulling
  // from window.StacksNetwork mirrors what scripts/deploy.mjs does.
  // netConst was already resolved above from cfg / network arg.

  // 4. Build unsigned deploy tx. Same builder the deploy script uses.
  const tx = await T.makeUnsignedContractDeploy({
    contractName:      name,
    codeBody:          source,
    publicKey:         userPubKey,
    network:           netConst,
    fee:               BigInt(feeMicroStx),
    nonce,
    clarityVersion:    cv,
    postConditionMode: T.PostConditionMode?.Deny ?? 2,
  });
  // @stacks/transactions v7 broke the bytesToHex export. tx.serialize() in
  // v7 returns a hex string already (in v6 it returned bytes). Fall back to
  // serializeBytes + manual hex if a build still gives us a Uint8Array.
  const serialized = tx.serialize();
  const unsignedHex =
    typeof serialized === "string"
      ? serialized.replace(/^0x/, "")
      : bytesToHexLocal(serialized);
  // Sanity log: the first 10 bytes of a Stacks unsigned tx encode
  //   [version_byte: 1] [chain_id: 4] [auth_type: 1] [hash_mode: 1] ...
  // Mainnet -> "00 00000001 ..." | Testnet -> "80 80000000 ..."
  // If we see "80" + "80000000" here but `network === 'mainnet'`,
  // @stacks/network@7's STACKS_MAINNET is broken on esm.sh and our tx is
  // actually a testnet payload -> Xverse correctly rejects it.
  console.log("[WALLET] unsigned tx hex prefix (version+chainId):",
    unsignedHex.slice(0, 2), unsignedHex.slice(2, 10),
    "| expected for", network, "->",
    network === "mainnet" ? "00 00000001" : "80 80000000");
  // Auth section structural decode. After the 5-byte header:
  //   [authType: 1] [hashMode: 1] [hash160: 20] [nonce: 8] [fee: 8] [keyEnc: 1] [sig: 65]
  // First 84 bytes (= 168 hex chars) give us the complete unsigned auth.
  // Critically: hash160 should be the SAME for testnet and mainnet (it's
  // derived from the pubkey only, network-agnostic). Tx version differs.
  console.log("[WALLET] unsigned tx full auth section (first 168 hex chars):",
    unsignedHex.slice(0, 168));
  // Pull out specifically: authType (byte 5), hashMode (byte 6), hash160 (bytes 7-26).
  console.log("[WALLET] auth breakdown:", {
    versionByte: unsignedHex.slice(0, 2),
    chainId:     unsignedHex.slice(2, 10),
    authType:    unsignedHex.slice(10, 12),
    hashMode:    unsignedHex.slice(12, 14),
    hash160:     unsignedHex.slice(14, 54),
    nonce:       unsignedHex.slice(54, 70),
    fee:         unsignedHex.slice(70, 86),
    keyEncoding: unsignedHex.slice(86, 88),
  });
  console.log("[WALLET] @stacks/network full netConst:", JSON.stringify({
    chainId: netConst?.chainId,
    transactionVersion: netConst?.transactionVersion,
    peerNetworkId: netConst?.peerNetworkId,
    magicBytes: netConst?.magicBytes,
    bootAddress: netConst?.bootAddress,
    addressVersion: netConst?.addressVersion,
    clientBaseUrl: netConst?.client?.baseUrl,
  }, null, 2));

  // 5. Sign via wallet. Pass both `transaction` and `txHex` keys -- different
  // wallet builds historically used different names. SIP-030's
  // SignTransactionParams is {transaction, broadcast?} only -- the wallet
  // derives network from the tx bytes (chainId + version byte) themselves.
  // We pass `network` anyway as a defensive hint since some pre-SIP-030
  // wallet builds inspected the field. The real session-network binding
  // happens at connect() + stx_getAddresses; if those carry the right
  // network, stx_signTransaction will not pop a mismatch modal.
  // Build TWO param shapes:
  //   - xverseParams: STRICTLY matching Xverse's valibot schema for
  //     stx_signTransaction { transaction, pubkey?, broadcast? }. Extra
  //     fields like `network` or `txHex` cause valibot to reject the
  //     params, and Xverse appears to surface that as the generic
  //     "Mismatched Network" red modal (the message is misleading).
  //   - signParams: legacy shape with `txHex` + `network` for Leather and
  //     other wallets that still expect them.
  const xverseParams = {
    transaction: unsignedHex,
    pubkey:      userPubKey,
  };
  const signParams = {
    transaction: unsignedHex,
    txHex:       unsignedHex,
    network,
    pubkey:      userPubKey,
  };
  // Prefer Xverse's real provider when present, sidestepping Connect 8's
  // wrapping. Use BitcoinProvider (Xverse's canonical entry point per Connect
  // 8's DEFAULT_PROVIDERS -- handles all stx_/btc_/wallet_* methods via
  // internal routing); fall back to StacksProvider only if BitcoinProvider
  // is missing. NOTE: window.XverseProviders.StacksProvider can be hijacked
  // by Leather extension via Object.defineProperty(configurable:false),
  // leaving a stub that throws "request function is not implemented". We
  // saw this exact failure in the wild -- hence preferring BitcoinProvider.
  const xv = window.XverseProviders;
  const xverseStxProvider =
    (xv?.BitcoinProvider && typeof xv.BitcoinProvider.request === "function") ? xv.BitcoinProvider :
    (xv?.StacksProvider  && typeof xv.StacksProvider.request  === "function") ? xv.StacksProvider  :
    null;
  const useXverseDirect = !!xverseStxProvider;
  console.log("[WALLET] stx_signTransaction REQUEST:", {
    network,
    userAddress,
    userPubKey: userPubKey?.slice(0, 16) + "...",
    nonce: nonce.toString(),
    feeMicroStx,
    clarityVersion,
    contractName: name,
    unsignedHexBytes: unsignedHex?.length,
    selectedProviderId: typeof C8.getSelectedProviderId === "function" ? C8.getSelectedProviderId() : "n/a",
    hasXverse: !!window.XverseProviders?.StacksProvider,
    hasLeather: !!window.LeatherProvider,
    signPath: useXverseDirect ? "XverseDirect" : "Connect8",
  });
  // Pre-sign diagnostic: find out what Xverse THINKS is the active account
  // and which accounts are available. Xverse's "Network mismatch" error
  // (-32600) is generic and ALSO fires when the cached account's pubkey
  // (what we embedded in the tx hex) does not match Xverse's currently
  // selected account. Comparing live vs cached lets us surface the real
  // cause and switch tx authorship if needed.
  if (useXverseDirect) {
    try {
      const accResp = await xverseStxProvider.request("stx_getAccounts");
      console.log("[WALLET] stx_getAccounts pre-sign (full):", JSON.stringify(accResp, null, 2));
    } catch (e) {
      console.warn("[WALLET] stx_getAccounts pre-sign failed:", e?.message || e?.error?.message || JSON.stringify(e));
    }
    try {
      const addrResp = await xverseStxProvider.request("stx_getAddresses");
      const addrs = addrResp?.result?.addresses || addrResp?.addresses || [];
      const liveStx = addrs.find(a => /^(ST|SP)/.test(a?.address || ""));
      console.log("[WALLET] live stx_getAddresses pre-sign:", {
        liveAddress: liveStx?.address,
        livePubKey: liveStx?.publicKey?.slice(0,16) + "...",
        cachedAddress: userAddress,
        cachedPubKey: userPubKey?.slice(0,16) + "...",
        match: liveStx?.address === userAddress && liveStx?.publicKey === userPubKey,
      });
      if (liveStx?.address && liveStx?.address !== userAddress) {
        throw new Error(
          `Active Xverse account (${liveStx.address}) differs from the connected/cached ` +
          `account (${userAddress}). The tx was built for the cached pubkey but Xverse ` +
          `will try to sign with a different account. Switch to the right account in ` +
          `Xverse (top-right account picker) and retry, or Disconnect+Connect to refresh the cache.`
        );
      }
    } catch (e) {
      if (e?.message?.startsWith("Active Xverse account")) throw e;
      console.warn("[WALLET] stx_getAddresses pre-sign failed:", e?.message || e?.error?.message || JSON.stringify(e));
    }
    const xverseNetName = network === "mainnet" ? "Mainnet"
                       : network === "testnet" ? "Testnet"
                       : null;
    if (xverseNetName) {
      try {
        console.log("[WALLET] wallet_changeNetwork pre-sign:", { name: xverseNetName });
        const chgResp = await xverseStxProvider.request("wallet_changeNetwork", { name: xverseNetName });
        console.log("[WALLET] wallet_changeNetwork pre-sign RESPONSE:", JSON.stringify(chgResp));
      } catch (e) {
        console.warn("[WALLET] wallet_changeNetwork pre-sign failed:", e?.message || e?.error?.message || JSON.stringify(e));
      }
    }
    try {
      const netResp = await xverseStxProvider.request("wallet_getNetwork");
      console.log("[WALLET] wallet_getNetwork pre-sign (full):", JSON.stringify(netResp, null, 2));
    } catch (e) {
      console.warn("[WALLET] wallet_getNetwork pre-sign failed:", e?.message || e);
    }
  }
  let signResp;
  try {
    if (useXverseDirect) {
      // Raw JSON-RPC envelope returned by direct provider calls.
      // Use the strict Xverse-shaped params (no extra fields).
      console.log("[WALLET] Xverse direct sign params (strict schema):", {
        transactionLen: xverseParams.transaction.length,
        pubkey: xverseParams.pubkey?.slice(0,16) + "...",
      });
      const raw = await xverseStxProvider.request("stx_signTransaction", xverseParams);
      console.log("[WALLET] stx_signTransaction RAW:", raw);
      if (raw && typeof raw === "object" && "error" in raw && raw.error) {
        const err = raw.error;
        throw new Error(`Xverse sign error ${err.code ?? ""}: ${err.message || JSON.stringify(err)}`);
      }
      signResp = raw?.result || raw;
    } else {
      signResp = await C8.request("stx_signTransaction", signParams);
    }
    console.log("[WALLET] stx_signTransaction RESPONSE:", signResp);
  } catch (e) {
    console.error("[WALLET] stx_signTransaction THREW:", e, "code:", e?.code, "error:", e?.error);
    if (e?.error?.code === 4001) throw new Error("User rejected signing");
    if (e?.error) throw new Error(`Wallet sign error ${e.error.code}: ${e.error.message}`);
    throw new Error(`Wallet sign error: ${e?.message || JSON.stringify(e)}`);
  }
  const signedHex =
    signResp?.transaction ||
    signResp?.txHex ||
    signResp?.result?.transaction ||
    signResp?.result?.txHex;
  if (!signedHex) {
    throw new Error("Wallet returned no signed tx: " + JSON.stringify(signResp));
  }

  // 6. Broadcast signed bytes directly to the node.
  const signedBytes = hexToBytesLocal(String(signedHex).replace(/^0x/, ""));
  const broadcastR  = await fetch(`${c.apiHost}/v2/transactions`, {
    method:  "POST",
    headers: { "Content-Type": "application/octet-stream" },
    body:    signedBytes,
  });
  const txidRaw = (await broadcastR.text()).trim();
  if (!broadcastR.ok) {
    throw new Error(`Broadcast failed HTTP ${broadcastR.status}: ${txidRaw.slice(0, 300)}`);
  }
  // Node returns txid JSON-quoted; strip quotes and the optional 0x prefix.
  const txidClean = txidRaw.replace(/^"|"$/g, "").replace(/^0x/, "");
  if (!/^[0-9a-f]{64}$/i.test(txidClean)) {
    throw new Error(`Unexpected broadcast response: ${txidRaw.slice(0, 200)}`);
  }
  return { txid: `0x${txidClean}`, raw: signResp };
}

// Async polling: resolve when tx_status === "success", reject on terminal
// non-success states or `timeoutMs`. Used to gate tx2 (create-pool) on tx1
// (token deploy) confirmation -- we cannot broadcast tx2 until the new
// token contract is on-chain.
//
// Note: useTxTracker() is the React-hook variant (toast lifecycle). This
// async version is for sequential broadcast flows where the next step
// depends on the prior tx settling.
async function waitForTx(txid, cfg, opts = {}) {
  // Capture cfg into a closure so a network switch mid-poll cannot flip the
  // polling host (defense-in-depth -- the switch state machine gates on
  // no-pending-tx, so this should be unreachable in practice).
  const c          = resolveCfg(cfg);
  const intervalMs = opts.intervalMs || 5000;
  const timeoutMs  = opts.timeoutMs  || 5 * 60 * 1000; // 5 min default
  const startedAt  = Date.now();
  const url        = `${c.apiHost}/extended/v1/tx/${txid}`;

  while (Date.now() - startedAt < timeoutMs) {
    let tx = null;
    try {
      const r = await fetch(url, { cache: "no-store" });
      if (r.status === 404) {
        // mempool not seen yet -- keep polling
      } else if (r.ok) {
        tx = await r.json();
      }
      // other non-OK: transient, keep polling
    } catch (_) {
      // network blip -- keep polling
    }
    if (tx) {
      if (tx.tx_status === "success") return tx;
      if (tx.tx_status && tx.tx_status !== "pending") {
        // abort_by_response, abort_by_post_condition, dropped_*
        const code = parseTxErrorCode(tx);
        const msg  = code ? `${tx.tx_status} (${code})` : tx.tx_status;
        throw new Error(`tx aborted: ${msg}`);
      }
    }
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error(`tx ${txid} did not confirm within ${Math.round(timeoutMs / 1000)}s`);
}

// Pull a Clarity error code (e.g. "u217") out of a tx_result.repr like
// "(err u217)". Returns the code string or null.
function parseTxErrorCode(tx) {
  const repr = tx?.tx_result?.repr;
  if (!repr) return null;
  const m = repr.match(/\(err\s+(u\d+|\d+)\)/);
  return m ? m[1].replace(/^(\d)/, "u$1") : null;
}

// -------------------------------------------------------------------------
// Live pool enumeration via Hiro events endpoint
// -------------------------------------------------------------------------

// Recursively unwrap the verbose form returned by cvToValue(cv, true) in
// @stacks/transactions v7.x. Confirmed via the browser console: the lib
// returns { type, value } pairs rather than plain JS scalars, so
// downstream filters like `obj.event === "pool-created"` silently
// short-circuit on the wrapped object.
//
// The wrapped form is:
//   { type: "uint" | "(string-ascii N)" | "principal" | "tuple" | ...,
//     value: <inner> }
// For tuples: { type: "tuple", value: { key: wrapped, ... } }.
// For optionals: { type: "(optional ...)", value: wrapped | null }.
// Returns a plain JS shape: tuple -> object, optional -> inner | null,
// uint / int -> BigInt, bool -> boolean, others -> the inner string.
function unwrapCv(v) {
  if (v == null) return v;
  if (typeof v !== "object") return v;
  if (Array.isArray(v)) return v.map(unwrapCv);
  if (Object.prototype.hasOwnProperty.call(v, "type")
      && Object.prototype.hasOwnProperty.call(v, "value")) {
    const t = String(v.type);
    const inner = v.value;
    if (t.startsWith("(optional")) return inner == null ? null : unwrapCv(inner);
    if (t === "uint") {
      if (typeof inner === "bigint") return inner;
      if (typeof inner === "string") return BigInt(inner);
      return BigInt(0);
    }
    if (t === "int") {
      if (typeof inner === "bigint") return inner;
      if (typeof inner === "string") return BigInt(inner);
      return BigInt(0);
    }
    if (t === "bool") return !!inner;
    if (t === "tuple" || t.startsWith("(tuple")) {
      const out = {};
      if (inner && typeof inner === "object") {
        for (const k of Object.keys(inner)) out[k] = unwrapCv(inner[k]);
      }
      return out;
    }
    // string-ascii, string-utf8, principal, buff -- inner is already plain.
    return inner;
  }
  // Plain object (already unwrapped) -- recurse defensively.
  const out = {};
  for (const k of Object.keys(v)) out[k] = unwrapCv(v[k]);
  return out;
}

// Coerce a Clarity-decoded uint/string/number into a plain JS Number.
// cvToValue with recursive=true returns BigInt for uint and strings for
// principals; we only ever read uints here. Returns 0 on any non-numeric.
function _poolToNum(v) {
  if (typeof v === "bigint") return Number(v);
  if (typeof v === "number") return v;
  if (typeof v === "string") {
    const n = Number(v.replace(/^u/, ""));
    return Number.isFinite(n) ? n : 0;
  }
  return 0;
}

// Regex fallback: parse the (tuple (k v) ...) repr string when hexToCV is
// unavailable. We only extract the fields needed to gate downstream reads:
// `event`, `token`, and `mode`. The rest are filled from get-pool.
function _decodePrintRepr(repr) {
  if (!repr || typeof repr !== "string") return null;
  const eventM = repr.match(/\(event\s+"([^"]+)"\)/);
  const tokenM = repr.match(/\(token\s+'([^)' ]+)\)/);
  const modeM  = repr.match(/\(mode\s+u(\d+)\)/);
  if (!eventM || !tokenM) return null;
  return {
    event: eventM[1],
    token: tokenM[1],
    mode:  modeM ? BigInt(modeM[1]) : 0n,
  };
}

// Build a pool row shape-compatible with the mock POOLS array in data.jsx,
// so PoolList/PoolRow can render live and mock entries through one code
// path. Reserves/threshold are converted micro-STX -> STX for display.
function _composePoolRow(tokenPrincipal, pool, event) {
  // Token contract-name part (after the dot in "ADDR.contract").
  const nameSlug = tokenPrincipal.includes(".")
    ? tokenPrincipal.split(".")[1]
    : tokenPrincipal;
  const id     = nameSlug.slice(Math.max(0, nameSlug.length - 6)).toLowerCase();
  const name   = nameSlug
    .split("-")
    .map(s => s.length ? s.charAt(0).toUpperCase() + s.slice(1) : s)
    .join(" ");
  const symbol = nameSlug.replace(/-/g, "").toUpperCase().slice(0, 10);

  // Authoritative current mode: get-pool. Bonding pools transition to u2
  // on graduation inside the singleton, so reading the live tuple is
  // canonical -- the create-pool event mode is only correct at creation.
  const modeRaw = pool.mode;
  const modeNum = typeof modeRaw === "bigint" ? Number(modeRaw) : Number(modeRaw || 0);
  const mode    = modeNum === 0 ? "direct" : modeNum === 1 ? "bonding" : "graduated";

  const stxReserveU   = _poolToNum(pool["stx-reserve"]);
  const tokenReserveU = _poolToNum(pool["token-reserve"]);
  const stxReserve    = stxReserveU / 1_000_000;
  const tokenReserve  = tokenReserveU / 1_000_000;
  const price         = tokenReserveU > 0 ? stxReserveU / tokenReserveU : 0;

  const createdAtH  = _poolToNum(pool["created-at"]);
  const createdAt   = createdAtH > 0 ? `block #${createdAtH}` : "";

  const feeReceiver = pool["fee-receiver"]
    || (event && event["fee-receiver"])
    || "";

  const row = {
    id,
    name,
    symbol,
    decimals: 6,
    principal: tokenPrincipal,
    mode,
    stxReserve,
    tokenReserve,
    tvl: stxReserve,
    vol24: 0,
    burn24: 0,
    burnTotal: 0,
    price,
    change24: 0,
    active: pool.active === true,
    createdAt,
    feeReceiver,
    isLive: true,
    _createdAtNum: createdAtH,
  };

  if (modeNum === 1 || modeNum === 2) {
    row.virtualStx          = _poolToNum(pool["virtual-stx"])         / 1_000_000;
    row.virtualToken        = _poolToNum(pool["virtual-token"])       / 1_000_000;
    row.bondedStxCollected  = _poolToNum(pool["bonded-stx-collected"])/ 1_000_000;
    row.graduationThreshold = _poolToNum(pool["graduation-threshold"])/ 1_000_000;
    row.bondedTokensSold    = _poolToNum(pool["bonded-tokens-sold"])  / 1_000_000;
  }

  return row;
}

// Enumerate live pools on the active network's singleton by pulling the
// last 50 contract events, filtering pool-created prints, deduping by
// token principal (keeping the most recent occurrence), reading get-pool
// for each in parallel, and composing rows that match the mock POOLS shape
// so PoolList can render them through the same code path.
//
// Returns an array sorted by `created-at` descending. Throws on network
// errors so the caller can surface a meaningful failure to the user.
async function listPoolsViaEvents(cfg) {
  const ST = window.StacksTransactions;
  const c  = resolveCfg(cfg);

  const eventsUrl = `${c.apiHost}/extended/v1/contract/${c.singleton}/events?limit=50`;
  console.log("[POOLS] listPoolsViaEvents:", { network: c.id, singleton: c.singleton, eventsUrl });
  const r = await fetch(eventsUrl, { cache: "no-store" });
  if (!r.ok) {
    const text = await r.text().catch(() => "");
    console.error("[POOLS] events HTTP error:", r.status, text.slice(0, 300));
    throw new Error(`listPoolsViaEvents events HTTP ${r.status}: ${text.slice(0, 200)}`);
  }
  const json    = await r.json();
  const results = Array.isArray(json.results) ? json.results : [];
  console.log("[POOLS] events response: total results =", results.length,
    "| event_type counts:",
    results.reduce((acc, ev) => { acc[ev.event_type] = (acc[ev.event_type]||0)+1; return acc; }, {}));

  // Smart-contract log + print topic + this singleton only. Defensive on
  // contract_id since Hiro's contract-scoped endpoint already filters by
  // contract, but explicit is cheap.
  const prints = results.filter(ev =>
    ev && ev.event_type === "smart_contract_log"
    && ev.contract_log
    && ev.contract_log.topic === "print"
    && (ev.contract_log.contract_id === c.singleton || true));
  console.log("[POOLS] print logs after filter:", prints.length);

  // Decode each event. Prefer hex via hexToCV + cvToValue (plain JS object).
  // Fall back to regex on `repr` only if the lib isn't loaded yet, and
  // warn so the supervisor sees the degraded path.
  const decoded = [];
  for (const ev of prints) {
    const cl  = ev.contract_log.value || {};
    let obj   = null;
    if (ST && typeof ST.hexToCV === "function" && typeof ST.cvToValue === "function" && cl.hex) {
      try {
        const cv = ST.hexToCV(cl.hex);
        obj = unwrapCv(ST.cvToValue(cv, true));
      } catch (_) {
        // fall through to regex
      }
    }
    if (!obj && cl.repr) {
      console.warn("[POOLS] hex decode unavailable, using repr regex fallback. repr:", cl.repr?.slice(0, 200));
      obj = _decodePrintRepr(cl.repr);
    }
    if (!obj) {
      console.warn("[POOLS] event skipped (decode failed):", { hex: cl.hex?.slice(0,80), repr: cl.repr?.slice(0,200) });
      continue;
    }
    if (obj.event !== "pool-created") {
      console.log("[POOLS] event skipped (not pool-created):", obj.event);
      continue;
    }
    if (!obj.token) {
      console.warn("[POOLS] event skipped (no token field):", obj);
      continue;
    }
    decoded.push(obj);
  }
  console.log("[POOLS] decoded pool-created events:", decoded.length, decoded.map(d => d.token));

  // Dedup by token principal. Hiro returns events newest-first, so the
  // first occurrence is the most recent. Defensive against any rare
  // duplicates from re-organizations or repeated broadcasts.
  const seen = new Map();
  for (const d of decoded) {
    if (!seen.has(d.token)) seen.set(d.token, d);
  }

  // Fan out get-pool reads in parallel. Skip tokens whose read fails or
  // returns none (pool de-listed or never created on this singleton).
  const tokens = Array.from(seen.keys());
  console.log("[POOLS] unique tokens to resolve via get-pool:", tokens.length, tokens);
  const reads = await Promise.all(tokens.map(async (tokenPrincipal) => {
    try {
      const res = await callReadOnly({
        contractAddress: c.deployer,
        contractName:    c.singletonName,
        fnName:          "get-pool",
        fnArgs:          [{ kind: "principal", value: tokenPrincipal }],
        sender:          c.deployer,
      }, c);
      console.log("[POOLS] get-pool OK:", tokenPrincipal, "→ pool present:", !!res.value);
      return { tokenPrincipal, pool: res.value };
    } catch (e) {
      console.warn("[POOLS] get-pool FAILED for", tokenPrincipal, "|", e && e.message);
      return { tokenPrincipal, pool: null };
    }
  }));

  const pools = [];
  for (const entry of reads) {
    const { tokenPrincipal, pool } = entry;
    if (!pool) {
      console.warn("[POOLS] dropping token (get-pool returned null):", tokenPrincipal);
      continue;
    }
    const event = seen.get(tokenPrincipal);
    pools.push(_composePoolRow(tokenPrincipal, pool, event));
  }

  pools.sort((a, b) => (b._createdAtNum || 0) - (a._createdAtNum || 0));
  console.log("[POOLS] final pools rendered:", pools.length, pools.map(p => p.principal));
  return pools;
}

// -------------------------------------------------------------------------
// Hooks
// -------------------------------------------------------------------------

// Polls `get-pool(token)` on the singleton every 30 s. Cancels on unmount.
// `tokenPrincipal` falsy = no fetch (useful for the demo-pool fallback while
// no real pool exists yet).
function useGetPool(tokenPrincipal, cfg) {
  const c = resolveCfg(cfg);
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    if (!tokenPrincipal) {
      setData(null); setLoading(false); setError(null);
      return;
    }
    let cancelled = false;
    let timer = null;

    const fetchOnce = async () => {
      setLoading(true);
      try {
        const result = await callReadOnly({
          contractAddress: c.deployer,
          contractName:    c.singletonName,
          fnName:          "get-pool",
          fnArgs:          [{ kind: "principal", value: tokenPrincipal }],
          sender:          c.deployer,
        }, c);
        if (!cancelled) {
          setData(result.value);  // unwrapped (optional/tuple)
          setError(null);
        }
      } catch (e) {
        if (!cancelled) setError(e);
      } finally {
        if (!cancelled) {
          setLoading(false);
          timer = setTimeout(fetchOnce, 30000);
        }
      }
    };

    fetchOnce();
    return () => {
      cancelled = true;
      if (timer) clearTimeout(timer);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tokenPrincipal, c?.id]);

  return { data, loading, error };
}

// Polls Hiro health every 60 s. Returns { status, latencyMs, height }.
// status: "ok" (<500ms) | "lag" (<2000ms) | "error".
function useApiHealth(cfg) {
  const c = resolveCfg(cfg);
  const [state, setState] = React.useState({ status: "idle", latencyMs: null, height: null });

  React.useEffect(() => {
    let cancelled = false;
    let timer = null;
    // Reset to idle on network switch so the pill briefly clears before the
    // next probe lands.
    setState({ status: "idle", latencyMs: null, height: null });

    const tick = async () => {
      const h = await getApiHealth(c);
      if (cancelled) return;
      let status;
      if (!h.ok) status = "error";
      else if (h.latencyMs < 500) status = "ok";
      else if (h.latencyMs < 2000) status = "lag";
      else status = "lag";  // > 2000 still reachable but slow
      setState({ status, latencyMs: h.latencyMs, height: h.height || null, error: h.error || null });
      timer = setTimeout(tick, 60000);
    };

    tick();
    return () => {
      cancelled = true;
      if (timer) clearTimeout(timer);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [c?.id]);

  return state;
}

// Polls /extended/v1/tx/{txid} every 5s while pending. Stops on confirm
// (success / abort_*) or after 90s timeout. Returns { status, tx, code, error }
// where status ∈ "idle" | "pending" | "success" | "error" | "timeout".
//
// `code` is the parsed Clarity error code ("u217") on contract revert, null
// otherwise. Pass a falsy `txid` to keep the hook idle (used per-toast so we
// can mount one polling instance per pending broadcast).
function useTxTracker(txid, cfg) {
  // Resolve cfg once and capture into the polling closure so a mid-poll
  // network switch cannot flip the host this toast is polling against.
  const c = resolveCfg(cfg);
  const [state, setState] = React.useState({ status: "idle", tx: null, code: null, error: null });

  React.useEffect(() => {
    if (!txid) {
      setState({ status: "idle", tx: null, code: null, error: null });
      return;
    }
    let cancelled = false;
    let timer = null;
    const t0 = Date.now();
    const TIMEOUT_MS = 90000;

    const poll = async () => {
      try {
        const r = await fetch(`${c.apiHost}/extended/v1/tx/${txid}`, { cache: "no-store" });
        if (cancelled) return;
        if (r.status === 404) {
          // Mempool not seen yet -- keep waiting.
          if (Date.now() - t0 > TIMEOUT_MS) {
            setState({ status: "timeout", tx: null, code: null, error: "tx not seen in 90s" });
            return;
          }
          setState(s => s.status === "pending" ? s : { status: "pending", tx: null, code: null, error: null });
          timer = setTimeout(poll, 5000);
          return;
        }
        if (!r.ok) {
          setState({ status: "error", tx: null, code: null, error: `HTTP ${r.status}` });
          return;
        }
        const tx = await r.json();
        const status = tx.tx_status;
        if (status === "pending") {
          setState({ status: "pending", tx, code: null, error: null });
          if (Date.now() - t0 > TIMEOUT_MS) {
            setState({ status: "timeout", tx, code: null, error: "still pending after 90s" });
            return;
          }
          timer = setTimeout(poll, 5000);
        } else if (status === "success") {
          setState({ status: "success", tx, code: null, error: null });
        } else {
          // abort_by_response / abort_by_post_condition / dropped_*
          const code = parseTxErrorCode(tx);
          setState({ status: "error", tx, code, error: status });
        }
      } catch (e) {
        if (!cancelled) setState({ status: "error", tx: null, code: null, error: String(e) });
      }
    };

    setState({ status: "pending", tx: null, code: null, error: null });
    poll();
    return () => {
      cancelled = true;
      if (timer) clearTimeout(timer);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [txid, c?.id]);

  return state;
}

// -------------------------------------------------------------------------
// Wire-up: globals for downstream text/babel scripts.
// -------------------------------------------------------------------------
window.HiroApi = {
  callReadOnly,
  getApiHealth,
  signAndBroadcast,
  signAndDeployContract,
  buildAndSignDeployContract,
  fetchTokenTemplateSource,
  waitForTx,
  parseTxErrorCode,
  resolveCfg,
  listPoolsViaEvents,
};
window.useGetPool = useGetPool;
window.useApiHealth = useApiHealth;
window.useTxTracker = useTxTracker;
