aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark
Commit message (Expand)AuthorAgeFilesLines
* A waiting board says what it waits for: the Moving line counts confirmations,...Adam Soltys2026-08-091-6/+12
* Boards show the server their funding tx, as mainnet now insistsAdam Soltys2026-08-052-1/+8
* hal speaks pver 5: preimages prove their size, and fees round up as oneAdam Soltys2026-08-057-54/+84
* A wallet too small to renew warns before its coins expire; dust never diesAdam Soltys2026-08-041-1/+7
* Ark gifts go down to 1 sat; negative amounts can't OOM the encoderAdam Soltys2026-08-042-0/+8
* A freshly imported wallet shows history, not a celebrationAdam Soltys2026-08-031-1/+15
* Ark: partial moves to Savings via an exact-amount splitAdam Soltys2026-08-021-3/+9
* Rebrand: hal → coinos v3Adam Soltys2026-08-021-4/+4
* Simplify: one Lightning path, one balance, fewer featuresAdam Soltys2026-08-011-26/+0
* Background NWC: the worker now answers — and pays — from the pouchAdam Soltys2026-08-011-2/+32
* Stop the nostr sync snapshot from importing another ASP's coinsAdam Soltys2026-08-011-1/+9
* Never render another ASP's Ark state before connectingAdam Soltys2026-08-011-0/+7
* Keep Ark state per ASP, not just per walletAdam Soltys2026-08-011-0/+14
* Client support for third-party HTLC vtxos (trustless swap bridges)Adam Soltys2026-07-314-5/+187
* Fix send-form UX around lightning pays; harden ln state machinesAdam Soltys2026-07-311-7/+33
* Dust-isolate arkoor outputs; prove lightning E2E on regtestAdam Soltys2026-07-312-93/+199
* Add Ark <-> Lightning via the ASP (captaind-native submarine swaps)Adam Soltys2026-07-316-40/+822
* Exit status: real progress, blocks-left countdown, honest failuresAdam Soltys2026-07-051-1/+15
* Exit works on plain-esplora regtest; human-readable ark status linesAdam Soltys2026-07-051-3/+14
* Dedupe ark receive movementsAdam Soltys2026-07-051-1/+13
* Unilateral exit: put vtxos on-chain without the serverAdam Soltys2026-07-054-8/+197
* Collaborative offboard: move the ark balance back on-chainAdam Soltys2026-07-053-1/+328
* Boarding UX: label the funding tx and show an in-flight ark balanceAdam Soltys2026-07-051-1/+7
* Ark: recover from stale vtxo stateAdam Soltys2026-07-043-4/+47
* Ark: real-time receives over the SubscribeMailbox gRPC streamAdam Soltys2026-07-042-38/+130
* Ark: history rows open a detail viewAdam Soltys2026-07-041-4/+7
* Ark: 'Payment received!' celebration for Ark receivesAdam Soltys2026-07-031-0/+14
* Ark: native-JS protocol client + persistent ArkManagerAdam Soltys2026-07-036-0/+1830
a> 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631
// Wrapper around Esplora-compatible block explorer APIs (mempool.space and
// Blockstream — same REST shape). Every outgoing request goes through one
// rate-aware proxy (#run) so that:
//   - offline mode can be enforced in a single place,
//   - requests are serialized and spaced (one at a time), and
//   - a 429 from ANY request immediately backs off ALL subsequent requests
//     (global exponential pause), easing back as requests succeed.

import { STAGING } from './build-flags.js';

// Block explorer selection, stored per network (see the per-network presets +
// nk() helper below). Each network defaults to its first esplora preset; users
// can pick another or a custom Esplora/electrs REST URL (their own node).
const EXPLORER_KEY = 'btc-wallet-explorer';

export function getExplorerConfig(net = getNetwork()) {
  try {
    const c = JSON.parse(localStorage.getItem(nk(EXPLORER_KEY, net)) || 'null');
    if (c && c.server) return { server: c.server, url: c.url || '' };
  } catch {}
  return { server: netDefaults(net).explorer, url: '' };
}

export function setExplorerConfig({ server, url }, net = getNetwork()) {
  try {
    localStorage.setItem(nk(EXPLORER_KEY, net), JSON.stringify({ server, url: url || '' }));
  } catch {}
}

// Active Bitcoin network, persisted globally. mutinynet is a public signet (30s
// blocks) for testing — same address format as testnet, its own explorer at
// mutinynet.com. Data-source choices are stored per network (see nk()).
const NETWORK_KEY = 'btc-wallet-network';
// Staging's default is mutinynet, but a "mainnet" stored BEFORE the staging
// flag existed would pin early testers to mainnet forever. Clear it once —
// anyone who picks mainnet on staging after this keeps their choice.
if (STAGING) {
  try {
    if (!localStorage.getItem(NETWORK_KEY + '-staged')) {
      localStorage.setItem(NETWORK_KEY + '-staged', '1');
      if (localStorage.getItem(NETWORK_KEY) === 'mainnet') localStorage.removeItem(NETWORK_KEY);
    }
  } catch {}
}
export const NETWORKS = [
  { id: 'mainnet', label: 'Mainnet' },
  { id: 'testnet', label: 'Testnet' },
  { id: 'signet', label: 'Signet' },
  { id: 'mutinynet', label: 'Mutinynet' },
  { id: 'regtest', label: 'Regtest' },
];
export function getNetwork() {
  try {
    const n = localStorage.getItem(NETWORK_KEY);
    if (NETWORKS.some((x) => x.id === n)) return n;
  } catch {}
  // Staging builds default fresh profiles to mutinynet (play money); a
  // stored choice — including mainnet — always wins over the default.
  return STAGING ? 'mutinynet' : 'mainnet';
}
export function setNetwork(net) {
  try { localStorage.setItem(NETWORK_KEY, net); } catch {}
}

// Per-network data-source presets — the single place network-specific endpoints
// live (so there are no duplicate URL fields elsewhere). Each network has its own
// Electrum servers and block explorers; the selection is stored per network, so
// switching networks remembers each one's choice. 'custom' points at your node.
// One unified list of data sources per network. Each entry knows its type —
// electrum (an Electrum-over-WS server: data + instant push) or esplora (a block
// explorer's REST API: data only, polled). The UI shows a single list; the wallet
// derives the backend from the selected source's type. A custom source's type is
// detected from its URL scheme (ws/wss → electrum, http/https → esplora).
const DATA_SOURCES_BY_NET = {
  mainnet: [
    { id: 'coinos', label: 'coinos', type: 'electrum', url: 'wss://electrum.coinos.io' },
    // Same Fulcrum, different apex: the coinos.io zone's edge security 403s
    // WS clients with poor IP reputation (VPN exits), and the public
    // fallbacks below are the usual suspects regional ISPs block wholesale.
    // This mirror rides infra anyone who can load the app provably reaches.
    { id: 'coinosalt', label: 'coinos (mirror)', type: 'electrum', url: 'wss://halwallet.app/electrum' },
    { id: 'mempool', label: 'mempool.space', type: 'esplora', base: 'https://mempool.space/api', web: 'https://mempool.space', kind: 'mempool' },
    { id: 'blockstream', label: 'blockstream.info', type: 'esplora', base: 'https://blockstream.info/api', web: 'https://blockstream.info', kind: 'esplora' },
    { id: 'electroncash', label: 'btc.electroncash.dk', type: 'electrum', url: 'wss://btc.electroncash.dk:60004' },
    { id: 'jochen', label: 'electrum.jochen-hoenicke.de', type: 'electrum', url: 'wss://electrum.jochen-hoenicke.de:50010' },
    { id: 'mempoolguide', label: 'mempool.guide', type: 'electrum', url: 'wss://mempool.guide/electrum-websocket/' },
    { id: 'custom', label: 'Custom', type: 'auto', url: '' },
  ],
  testnet: [
    { id: 'mempool', label: 'mempool.space', type: 'esplora', base: 'https://mempool.space/testnet/api', web: 'https://mempool.space/testnet', kind: 'mempool' },
    { id: 'blockstream', label: 'blockstream.info', type: 'esplora', base: 'https://blockstream.info/testnet/api', web: 'https://blockstream.info/testnet', kind: 'esplora' },
    { id: 'blockstreamel', label: 'blockstream.info (Electrum)', type: 'electrum', url: 'wss://blockstream.info/testnet/electrum-websocket/' },
    { id: 'custom', label: 'Custom', type: 'auto', url: '' },
  ],
  signet: [
    { id: 'mempool', label: 'mempool.space', type: 'esplora', base: 'https://mempool.space/signet/api', web: 'https://mempool.space/signet', kind: 'mempool' },
    { id: 'second', label: 'esplora.signet.2nd.dev', type: 'esplora', base: 'https://esplora.signet.2nd.dev', web: 'https://mempool.space/signet', kind: 'esplora' },
    { id: 'custom', label: 'Custom', type: 'auto', url: '' },
  ],
  mutinynet: [
    // coinos-hosted electrs against our own node: no CDN caching, no rate
    // limits — mutinynet.com stays as a fallback pick
    { id: 'coinos', label: 'coinos (staging esplora)', type: 'esplora', base: 'https://staging.coinos.io/esplora', web: 'https://mutinynet.com', kind: 'esplora' },
    { id: 'mutinynet', label: 'mutinynet.com', type: 'esplora', base: 'https://mutinynet.com/api', web: 'https://mutinynet.com', kind: 'esplora' },
    { id: 'custom', label: 'Custom', type: 'auto', url: '' },
  ],
  regtest: [
    { id: 'local', label: 'Local Fulcrum', type: 'electrum', url: '/electrum' },
    { id: 'localesplora', label: 'Local Esplora', type: 'esplora', base: '/esplora', web: 'http://localhost:3000', kind: 'esplora' },
    { id: 'custom', label: 'Custom', type: 'auto', url: '' },
  ],
};
const DATA_SOURCE_DEFAULT = { mainnet: 'coinos', testnet: 'mempool', signet: 'mempool', mutinynet: 'coinos', regtest: 'local' };
export function dataSources(net = getNetwork()) { return DATA_SOURCES_BY_NET[net] || DATA_SOURCES_BY_NET.mainnet; }
const detectType = (url) => (/^wss?:\/\//i.test((url || '').trim()) ? 'electrum' : 'esplora');

// Old per-network defaults (mode + server id) — kept only so the one-time
// migration from the old split electrum/explorer settings can read them.
const NET_DEFAULTS = {
  mainnet: { mode: 'electrum', electrum: 'coinos', explorer: 'mempool' },
  testnet: { mode: 'esplora', electrum: 'blockstreamel', explorer: 'mempool' },
  signet: { mode: 'esplora', electrum: 'custom', explorer: 'mempool' },
  mutinynet: { mode: 'esplora', electrum: 'custom', explorer: 'coinos' },
  regtest: { mode: 'electrum', electrum: 'local', explorer: 'localesplora' },
};
function netDefaults(net) { return NET_DEFAULTS[net] || NET_DEFAULTS.mainnet; }
// Internal filtered views (electrum fallover list + esplora host resolution).
export function electrumPresets(net = getNetwork()) { return dataSources(net).filter((x) => x.type === 'electrum'); }
export function explorerPresets(net = getNetwork()) { return dataSources(net).filter((x) => x.type === 'esplora'); }

// The selected data source, resolved to a full entry. Migrates from the old split
// settings the first time (until the user picks a source, which writes SOURCE_KEY).
const SOURCE_KEY = 'btc-wallet-source';
export function getSource(net = getNetwork()) {
  let sel = null;
  try { sel = JSON.parse(localStorage.getItem(nk(SOURCE_KEY, net)) || 'null'); } catch {}
  if (!sel || !sel.id) sel = migrateSource(net);
  const list = dataSources(net);
  if (sel.id === 'custom') {
    const url = (sel.url || '').trim();
    const type = detectType(url);
    if (type === 'electrum') return { id: 'custom', label: 'Custom', type, url };
    const base = url.replace(/\/+$/, '');
    return { id: 'custom', label: 'Custom', type, url, base, web: base.replace(/\/api$/, ''), kind: 'esplora' };
  }
  return list.find((x) => x.id === sel.id) || list.find((x) => x.id === DATA_SOURCE_DEFAULT[net]) || list[0];
}
export function setSource({ id, url }, net = getNetwork()) {
  try { localStorage.setItem(nk(SOURCE_KEY, net), JSON.stringify({ id, url: url || '' })); } catch {}
}
function migrateSource(net) {
  const list = dataSources(net);
  const has = (id) => list.some((x) => x.id === id);
  try {
    if (getDataMode(net) === 'electrum') {
      const ec = getElectrumServerConfig(net);
      if (ec.server === 'custom' && ec.url) return { id: 'custom', url: ec.url };
      if (has(ec.server)) return { id: ec.server };
    } else {
      const xc = getExplorerConfig(net);
      if (xc.server === 'custom' && xc.url) return { id: 'custom', url: xc.url };
      if (has(xc.server)) return { id: xc.server };
    }
  } catch {}
  return { id: DATA_SOURCE_DEFAULT[net] || list[0].id };
}
// Per-network storage keys: mainnet keeps the bare key (backward compat), others
// get a ":<net>" suffix.
function nk(base, net) { return net === 'mainnet' ? base : `${base}:${net}`; }

// Boltz swap provider — the api-v2 REST endpoint the SwapManager talks to; the
// swap-status WebSocket is derived from it (http->ws + /v2/ws) unless given.
// Presets are independent Boltz-compatible instances from SwapMarket's list
// (swapmarket.github.io); `local` is the regtest stack. Non-custodial either
// way: a provider can fail a swap but never steal (we claim/refund on-chain).
// `nets` is which Bitcoin networks a preset actually serves — a provider runs on
// exactly one chain, so a regtest/mutinynet backend must never be offered (or
// used) on mainnet, and vice versa. `custom` carries no `nets` and is available
// everywhere.
export const BOLTZ_PRESETS = [
  { id: 'local', label: 'Local (regtest)', api: '/boltz', ws: 'ws://localhost:9004/v2/ws', nets: ['regtest'] },
  { id: 'staging', label: 'coinos staging (mutinynet)', api: 'https://swap-staging.coinos.io', ws: 'wss://swap-staging.coinos.io/v2/ws', nets: ['mutinynet'] },
  { id: 'coinos', label: 'coinos (swap.coinos.io)', api: 'https://swap.coinos.io', ws: 'wss://swap.coinos.io/v2/ws', nets: ['mainnet'] },
  { id: 'boltz', label: 'Boltz Exchange', api: 'https://api.boltz.exchange', nets: ['mainnet'] },
  { id: 'middleway', label: 'Middle Way', api: 'https://api.middle-way.space', nets: ['mainnet'] },
  { id: 'zeus', label: 'ZEUS Swaps', api: 'https://swaps.zeuslsp.com/api', nets: ['mainnet'] },
  { id: 'eldamar', label: 'Eldamar', api: 'https://boltz-api.eldamar.icu', nets: ['mainnet'] },
  { id: 'custom', label: 'Custom…', api: '', ws: '' },
];

// Presets valid for a network: those that serve it, plus Custom (any network).
export function boltzPresetsFor(net = getNetwork()) {
  return BOLTZ_PRESETS.filter((p) => !p.nets || p.nets.includes(net));
}
const BOLTZ_PROVIDER_KEY = 'btc-wallet-boltz-provider'; // selected preset id
const BOLTZ_CUSTOM_KEY = 'btc-wallet-boltz-custom';     // { api, ws } for custom

export function getBoltzCustom() {
  try { const c = JSON.parse(localStorage.getItem(BOLTZ_CUSTOM_KEY) || 'null'); if (c) return { api: c.api || '', ws: c.ws || '' }; } catch {}
  return { api: '', ws: '' };
}
export function setBoltzCustom({ api, ws }) {
  try { localStorage.setItem(BOLTZ_CUSTOM_KEY, JSON.stringify({ api: (api || '').trim(), ws: (ws || '').trim() })); } catch {}
}
export function getBoltzProviderId() {
  const net = getNetwork();
  // Only honour a stored choice that's valid for the CURRENT network — otherwise
  // switching to mainnet would silently keep a regtest/mutinynet backend.
  try { const id = localStorage.getItem(BOLTZ_PROVIDER_KEY); if (id && boltzPresetsFor(net).some((p) => p.id === id)) return id; } catch {}
  // Default per network. Mainnet uses public Boltz Exchange until our own
  // swap.coinos.io node ('coinos') has channel liquidity; flip this to 'coinos'
  // once funded. No Boltz-compatible instance runs on signet/testnet, so they
  // default to custom/empty.
  return net === 'regtest' ? 'local' : net === 'mutinynet' ? 'staging' : (net === 'signet' || net === 'testnet') ? 'custom' : 'boltz';
}
export function setBoltzProviderId(id) { try { localStorage.setItem(BOLTZ_PROVIDER_KEY, id); } catch {} }

export function getBoltzProvider() {
  const p = BOLTZ_PRESETS.find((x) => x.id === getBoltzProviderId()) || BOLTZ_PRESETS.find((x) => x.id === 'custom');
  if (p.id === 'custom') { const c = getBoltzCustom(); return { id: 'custom', api: c.api, ws: c.ws }; }
  return { id: p.id, api: p.api, ws: p.ws || '' };
}
const deriveBoltzWs = (api) => api ? api.replace(/^http/, 'ws').replace(/\/+$/, '') + '/v2/ws' : '';
export function getBoltzApi() { return getBoltzProvider().api; }
export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deriveBoltzWs(p.api); }

// Ark server (ASP) — Second's bark/captaind protocol, spoken natively over
// gRPC-web (src/ark). Off by default: enabling Ark is an explicit opt-in per
// network. The esplora URL is the chain source the Ark client checks anchors
// and confirmations against (independent from the wallet's own data source so
// each preset ships a known-good pairing).
const ARK_PRESETS_BY_NET = {
  mainnet: [
    { id: 'off', label: 'Off', ark: '', esplora: '' },
    { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api' },
    { id: 'second', label: 'Second (ark.second.tech)', ark: 'https://ark.second.tech', esplora: 'https://mempool.second.tech/api' },
    { id: 'custom', label: 'Custom…', ark: '', esplora: '' },
  ],
  // No public ASP on these networks (Second runs signet + mainnet only) —
  // custom lets you point at a self-hosted captaind.
  testnet: [
    { id: 'off', label: 'Off', ark: '', esplora: '' },
    { id: 'custom', label: 'Custom…', ark: '', esplora: '' },
  ],
  signet: [
    { id: 'off', label: 'Off', ark: '', esplora: '' },
    { id: 'second', label: 'Second (ark.signet.2nd.dev)', ark: 'https://ark.signet.2nd.dev', esplora: 'https://esplora.signet.2nd.dev' },
    { id: 'custom', label: 'Custom…', ark: '', esplora: '' },
  ],
  mutinynet: [
    { id: 'off', label: 'Off', ark: '', esplora: '' },
    { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora' },
    { id: 'custom', label: 'Custom…', ark: '', esplora: '' },
  ],
  regtest: [
    { id: 'off', label: 'Off', ark: '', esplora: '' },
    { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002' },
    { id: 'custom', label: 'Custom…', ark: '', esplora: '' },
  ],
};
// On by default where a known-good server exists; the Settings card can
// always turn it off. NOTE for anyone flipping the mainnet default: ark
// state is namespaced per server, so a user who never explicitly picked a
// provider follows the default — their coins on the old ASP stay safe under
// its namespace but disappear from view until they reselect it in Settings.
const ARK_DEFAULT = { mainnet: 'coinos', testnet: 'off', signet: 'second', mutinynet: 'coinos', regtest: 'local' };
const ARK_PROVIDER_KEY = 'btc-wallet-ark-provider'; // selected preset id, per network
const ARK_CUSTOM_KEY = 'btc-wallet-ark-custom';     // { ark, esplora } for custom, per network

export function arkPresets(net = getNetwork()) {
  return ARK_PRESETS_BY_NET[net] || ARK_PRESETS_BY_NET.mainnet;
}
export function getArkProviderId(net = getNetwork()) {
  // A stored choice wins on every network (Settings → Advanced → Ark server
  // offers coinos, Second, or a custom server); the default is coinos.
  try {
    const id = localStorage.getItem(nk(ARK_PROVIDER_KEY, net));
    if (id && arkPresets(net).some((p) => p.id === id)) return id;
  } catch {}
  return ARK_DEFAULT[net] || 'off';
}
export function setArkProviderId(id, net = getNetwork()) {
  try { localStorage.setItem(nk(ARK_PROVIDER_KEY, net), id); } catch {}
}
export function getArkCustom(net = getNetwork()) {
  try { const c = JSON.parse(localStorage.getItem(nk(ARK_CUSTOM_KEY, net)) || 'null'); if (c) return { ark: c.ark || '', esplora: c.esplora || '' }; } catch {}
  return { ark: '', esplora: '' };
}
export function setArkCustom({ ark, esplora }, net = getNetwork()) {
  try { localStorage.setItem(nk(ARK_CUSTOM_KEY, net), JSON.stringify({ ark: (ark || '').trim(), esplora: (esplora || '').trim() })); } catch {}
}
// The active Ark endpoints, or null when Ark is off / incompletely configured.
export function getArkConfig(net = getNetwork()) {
  const id = getArkProviderId(net);
  if (id === 'off') return null;
  const p = arkPresets(net).find((x) => x.id === id);
  const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora };
  return cfg.ark && cfg.esplora ? cfg : null;
}

// Resolve the configured explorer to the host list the Api tries in order.
function resolveHosts(net) {
  const src = getSource(net);
  const mk = (p) => ({ base: p.base, kind: p.kind || 'esplora', web: p.web || (p.base || '').replace(/\/api$/, ''), cooldownUntil: 0 });
  if (src.type === 'esplora' && src.base) {
    // Mainnet silently fails mempool.space over to blockstream.info.
    if (net === 'mainnet' && src.id === 'mempool') { const bs = dataSources(net).find((x) => x.id === 'blockstream'); return bs ? [mk(src), mk(bs)] : [mk(src)]; }
    return [mk(src)];
  }
  // Electrum (or an empty custom) selected: no esplora data host, but tx links
  // still need a web base, so use the network's default explorer host.
  const def = dataSources(net).find((x) => x.type === 'esplora');
  return [def ? mk(def) : { base: '', kind: 'esplora', web: 'https://mempool.space', cooldownUntil: 0 }];
}

// The "data source" setting (stored per network) picks where chain data +
// payment notifications come from:
//   electrum — an Electrum-over-WS server for both data and instant push. The
//              default server is coinos's own Fulcrum on mainnet (see the
//              per-network presets), or point it at your own node.
//   explorer — block-explorer REST data only; polls for payments, no node.
// getBackend() derives from it. Old global 'coinos' mode maps to 'electrum'.
const DATA_MODE_KEY = 'btc-wallet-mode';
export function getDataMode(net = getNetwork()) {
  try {
    const m = localStorage.getItem(nk(DATA_MODE_KEY, net));
    if (m === 'electrum' || m === 'explorer') return m;
    if (m === 'coinos') return 'electrum'; // coinos is now the default Electrum server
  } catch {}
  return netDefaults(net).mode;
}
export function setDataMode(m, net = getNetwork()) {
  try { localStorage.setItem(nk(DATA_MODE_KEY, net), m === 'explorer' ? 'explorer' : 'electrum'); } catch {}
}

export function getBackend() {
  return getSource().type === 'electrum' ? 'electrum' : 'esplora';
}
// The legacy coinos watcher shim is retired (coinos is a full Electrum server
// now); 'explorer' mode polls for payments instead of subscribing.
export function getRealtimeEnabled() { return false; }

const ELECTRUM_SERVER_KEY = 'btc-wallet-electrum-server';
export function getElectrumServerConfig(net = getNetwork()) {
  try {
    const c = JSON.parse(localStorage.getItem(nk(ELECTRUM_SERVER_KEY, net)) || 'null');
    if (c && c.server) return { server: c.server, url: c.url || '' };
  } catch {}
  return { server: netDefaults(net).electrum, url: '' };
}
export function setElectrumServerConfig({ server, url }, net = getNetwork()) {
  try { localStorage.setItem(nk(ELECTRUM_SERVER_KEY, net), JSON.stringify({ server, url: url || '' })); } catch {}
}
// Ordered Electrum WS candidates for the current selection — the wallet tries
// them in turn, advancing when one fails to connect. A named public preset falls
// over to the network's other public servers for resilience; a custom your-node
// URL does NOT fall over — we won't silently leak your addresses to a server you
// didn't pick.
const PUBLIC_ELECTRUM = (net) => electrumPresets(net).filter((x) => x.url).map((x) => x.url);
export function electrumCandidates(net = getNetwork()) {
  const src = getSource(net);
  if (src.type !== 'electrum' || !src.url) return [];
  if (src.id === 'custom') return [src.url]; // your node — don't fall over to public servers
  return [src.url, ...PUBLIC_ELECTRUM(net).filter((u) => u !== src.url)];
}
export function resolveElectrumUrl() {
  return electrumCandidates()[0] || null;
}

// The Electrum backend (data + realtime push) selects/rotates candidates in the
// wallet via electrumCandidates(); the esplora backend has no push and polls. So
// there's no separate watcher URL anymore — wsUrl is always null.
export function wsUrl() { return null; }

// Web base for tx links (Electrum has no web UI — reuse the chosen explorer host).
export function explorerWeb(net = getNetwork()) {
  const hosts = resolveHosts(net);
  return (hosts[0] && hosts[0].web) || 'https://mempool.space';
}

const REQUEST_TIMEOUT_MS = 10000;

export class Api {
  constructor(net = 'mainnet') {
    this.net = net;
    this.offline = false;

    // Hosts are tried in order; a host that 429s is parked on cooldown so we
    // stop hammering it (Blockstream rate-limits aggressively).
    this._hosts = resolveHosts(net);
    this._timeoutMs = REQUEST_TIMEOUT_MS;

    // Serialized scheduler: one request at a time, min gap between starts.
    this._active = 0;
    this._maxConcurrent = 1;
    this._minGapMs = 500;
    this._nextStart = 0;
    this._queue = [];

    // Global rate-limit state, shared across every request and host.
    this._pauseUntil = 0; // no request may start before this time
    this._penalty = 0; // current backoff level
    this._okStreak = 0; // consecutive successes (used to ease the penalty)
  }

  explorerTx(txid) {
    // web already carries any network path prefix (e.g. .../testnet, mutinynet.com).
    const web = (this._hosts[0] && this._hosts[0].web) || 'https://mempool.space';
    return web + '/tx/' + txid;
  }

  get isRegtest() { return this.net === 'regtest'; }

  // ---- rate-aware global scheduler --------------------------------------
  #schedule(task) {
    return new Promise((resolve, reject) => {
      this._queue.push({ task, resolve, reject });
      this.#pump();
    });
  }

  #pump() {
    while (this._active < this._maxConcurrent && this._queue.length) {
      const { task, resolve, reject } = this._queue.shift();
      this._active++;
      const now = Date.now();
      // Respect both the per-request spacing AND any global backoff pause.
      const startAt = Math.max(now, this._nextStart, this._pauseUntil);
      this._nextStart = startAt + this._minGapMs;
      setTimeout(() => {
        Promise.resolve()
          .then(task)
          .then(resolve, reject)
          .finally(() => {
            this._active--;
            this.#pump();
          });
      }, startAt - now);
    }
  }

  // A 429 anywhere → pause every outgoing request, growing the pause each time.
  #penalize() {
    this._penalty = Math.min(this._penalty + 1, 6);
    this._okStreak = 0;
    const backoff = Math.min(2000 * 2 ** (this._penalty - 1), 30000); // 2s→30s
    this._pauseUntil = Date.now() + backoff;
  }

  #reward() {
    if (this._penalty === 0) return;
    if (++this._okStreak >= 4) {
      this._penalty--;
      this._okStreak = 0;
    }
  }

  // The single choke-point: fetch a given host through the scheduler. A 429
  // parks that host on cooldown (so we route away from it) and triggers the
  // global backoff pause.
  async #run(host, path, opts) {
    if (this.offline) throw new Error('offline');
    return this.#schedule(async () => {
      // Hard timeout: an unresponsive host must not stall the (serialized)
      // queue. On timeout/network error, park the host and fail over.
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), this._timeoutMs);
      let res;
      try {
        res = await fetch(host.base + path, { ...opts, signal: ctrl.signal });
      } catch (e) {
        host.cooldownUntil = Date.now() + 20000; // unresponsive — route away
        throw e;
      } finally {
        clearTimeout(timer);
      }
      if (res.status === 429) {
        host.cooldownUntil = Date.now() + 30000;
        this.#penalize();
        const e = new Error('rate limited');
        e.rateLimited = true;
        throw e;
      }
      this.#reward();
      return res;
    });
  }

  // First host not on cooldown (mempool preferred), or null if all are cooling.
  #pickHost() {
    const now = Date.now();
    for (const h of this._hosts) if (h.cooldownUntil <= now) return h;
    return null;
  }

  async #get(path, asText = false, { notFoundNull = false } = {}) {
    if (this.offline) throw new Error('offline');
    let lastErr;
    for (let attempt = 0; attempt < 6; attempt++) {
      let host = this.#pickHost();
      if (!host) {
        // Every host is cooling down — wait for the soonest to recover.
        const soonest = Math.min(...this._hosts.map((h) => h.cooldownUntil));
        await sleep(Math.max(300, soonest - Date.now()));
        host = this.#pickHost() || this._hosts[0];
      }
      try {
        const res = await this.#run(host, path);
        if (notFoundNull && res.status === 404) return null; // definitive: not an error
        if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`);
        return asText ? await res.text() : await res.json();
      } catch (e) {
        lastErr = e;
        if (!e.rateLimited) await sleep(300);
      }
    }
    throw lastErr;
  }

  // chain_stats / mempool_stats tell us whether an address has ever been used.
  addressInfo(address) {
    return this.#get(`/address/${address}`);
  }

  addressUtxos(address) {
    return this.#get(`/address/${address}/utxo`);
  }

  // Spend status of one output: { spent, txid (spender), vin, status }. Reports
  // a spend even by an unconfirmed tx — used to tell if a gift was already claimed.
  outspend(txid, vout) {
    return this.#get(`/tx/${txid}/outspend/${vout}`);
  }

  addressTxs(address) {
    return this.#get(`/address/${address}/txs`);
  }

  // Full transaction (vin with prevouts, vout, status) — used for fee bumping.
  getTx(txid) {
    return this.#get(`/tx/${txid}`);
  }

  // Confirmation status of one tx, or null when the node no longer knows the
  // txid at all — i.e. it was RBF-replaced or evicted from the mempool. The
  // 404 is the answer we're after, so it doesn't retry or fail over. NOTE:
  // /tx/:txid/status is NOT usable for this — electrs and mempool.space both
  // answer 200 {confirmed:false} for unknown txids; only /tx/:txid 404s.
  async txStatus(txid) {
    const tx = await this.#get(`/tx/${txid}`, false, { notFoundNull: true });
    return tx ? tx.status || { confirmed: false } : null;
  }

  async feeRates() {
    for (const host of this._hosts) {
      if (host.cooldownUntil > Date.now()) continue;
      const isMempool = host.kind === 'mempool';
      try {
        const res = await this.#run(host, isMempool ? '/v1/fees/recommended' : '/fee-estimates');
        if (!res.ok) continue;
        const data = await res.json();
        if (isMempool && data && data.halfHourFee) return data;
        if (!isMempool && data) return mapEsploraFees(data);
      } catch {
        /* try next host */
      }
    }
    return { fastestFee: 20, halfHourFee: 10, hourFee: 5, economyFee: 2, minimumFee: 1 };
  }

  // Broadcast to ALL explorers in parallel (not via the throttle — a send is
  // urgent) so the tx is visible regardless of which explorer the recipient
  // watches. Resolves with the txid if any accept it; rejects only if all fail.
  async broadcast(hexTx) {
    if (this.offline) throw new Error('offline');
    const attempts = this._hosts.map(async (host) => {
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), this._timeoutMs);
      try {
        // Keep the abort timer active across BOTH the request and the body read —
        // a stalled response body must not hang the broadcast forever.
        const res = await fetch(host.base + '/tx', { method: 'POST', body: hexTx, signal: ctrl.signal });
        const body = await res.text();
        if (res.status === 429) {
          host.cooldownUntil = Date.now() + 30000;
          this.#penalize();
        }
        if (!res.ok) throw new Error(body || `${res.status} broadcast failed`);
        return body.trim(); // txid
      } finally {
        clearTimeout(timer);
      }
    });
    const results = await Promise.allSettled(attempts);
    const ok = results.find((r) => r.status === 'fulfilled');
    if (ok) return ok.value;
    throw (results.find((r) => r.status === 'rejected') || {}).reason || new Error('broadcast failed');
  }
}

// Esplora /fee-estimates is { blockTarget: sat/vB }. Map to the named tiers the
// UI expects. Round up so we never underpay.
export function mapEsploraFees(est) {
  const at = (n, d) => Math.max(1, Math.ceil(est[n] || d));
  return {
    fastestFee: at(1, 10),
    halfHourFee: at(3, 5),
    hourFee: at(6, 3),
    economyFee: at(144, 2),
    minimumFee: at(1008, 1),
  };
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

// Run async jobs with a small concurrency cap. (The global scheduler is the
// real limiter; this just bounds how many are queued at once.)
export async function pool(items, worker, concurrency = 4) {
  const results = new Array(items.length);
  let next = 0;
  async function run() {
    while (next < items.length) {
      const i = next++;
      results[i] = await worker(items[i], i);
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
  return results;
}