aboutsummaryrefslogtreecommitdiffstats
path: root/lib/locales/es.json
blob: 635d04b358b68f81e195cef20aa0cb7617051f89 (plain) (blame)
1
2
3
4
5
6
7
{
	"hello": "Hola",
	"youReceived": "Recibiste un pago de",
	"withdrawalTriggered": "Esto provocó la retirada de",
	"problemWithdrawing": "Hubo un problema al enviar a su dirección de retiro automático",
	"paymentReceived": "Pago recibido"
}
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 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 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037
// Nostr Wallet Connect (NIP-47) — coinos as a *wallet service*.
//
// Other apps (Damus, Amethyst, Alby, a website) connect to your coinos wallet
// over nostr and can ask it to pay invoices or report a balance, without ever
// touching your keys. You hand out a connection string; coinos answers requests
// signed by that connection, inside the budget you set, and you can revoke it
// at any time.
//
// coinos holds no native Lightning balance, so every Lightning operation here
// runs over Ark: the ASP pays and mints invoices. That means NWC
// availability tracks Ark availability — no Ark server, no wallet service.
//
// Shape of the protocol (mirrors coinos-server/lib/nwc.ts):
//   kind 13194  info event, content = space-separated supported methods
//   kind 23194  request  (client -> wallet), encrypted to the wallet key
//   kind 23195  response (wallet -> client), same encryption scheme back
// Encryption is nip04 unless the request carries `["encryption","nip44_v2"]`.
//
// While a tab is open it answers directly; with background answering on,
// the service worker answers from a mirrored state (see nwc-bg.js) so the
// wallet keeps working closed.

import { hex } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import {
  nip04, nip44, getPublicKey, finalizeEvent, generateSecretKey,
  subscribeOn, publishOn, queryOn,
} from '../nostr.js';
import { t } from '../i18n.js';
import { dlog } from '../debug.js';
import { qrSvg } from '../qr.js';
import { getNetwork } from '../api.js';
import { loadBg, saveBg, clearBg } from '../nwc-bg.js';
import { encodeNoffer } from '../noffer.js';
import { maybeBolt11 } from '../ark/lightning.js';
import { withRequestLock } from '../nwc-lock.js';

const REQ_KIND = 23194;
const RES_KIND = 23195;
const INFO_KIND = 13194;

// What we can actually honour. No pay_keysend (Ark can't), no multi_pay.
const METHODS = ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'];

// Requests older than this are ignored — a relay replaying history must not
// re-trigger payments.
const MAX_AGE_SEC = 120;

const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (code, message) => ({ error: { code, message } });

export function nwcFeature(ctx) {
  const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx;
  // Relay transport, injectable so the protocol can be driven in tests
  // without a live relay.
  const {
    subscribe = subscribeOn, publish = publishOn, query = queryOn,
    // how long a pay_invoice error (or a sibling's success) waits for the
    // device that actually paid to speak first
    errGraceMs = 3000,
    claim = claimOn,
  } = ctx.nwcTransport || {};

  // Cross-device leadership for a pay request. Every awake device of the
  // wallet hears every request within the same second; the tab lock only
  // covers one browser profile, and two profiles (or two machines) each
  // locked an HTLC for the same zap. The notifier hands each request to the
  // first device that asks. Unreachable notifier → answer anyway: a lone
  // device must never go silent over a hiccup here.
  // Returns true (ours), false (someone else's) or null (no verdict: the
  // notifier could not be reached).
  const claimAs = Math.random().toString(36).slice(2, 12);
  async function claimOn(id) {
    try {
      const r = await fetch(`${NOTIFIER}/claim`, {
        method: 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ event: id, by: claimAs }),
        signal: AbortSignal.timeout ? AbortSignal.timeout(4000) : undefined,
      });
      if (!r.ok) return null;
      return (await r.json()).ok !== false;
    } catch { return null; }
  }
  // A phone answering from behind another app is throttled to a crawl and
  // still tends to win a race it will then lose by ten seconds; let any
  // desktop tab claim first.
  const mobile = typeof navigator !== 'undefined' && /Mobi|Android/i.test(navigator.userAgent || '');

  // ---- persisted connections -------------------------------------------
  // { id, name, secret (client sk hex), clientPk, servicePk, serviceSk,
  //   maxSat, dailySat, spentToday, spentDate, created, lastUsed, revoked }
  const load = () => wallet.loadFeatureState('nwc', { conns: [] });
  const save = (st) => wallet.saveFeatureState('nwc', st);
  const conns = () => (load().conns || []).filter((c) => !c.revoked);

  function updateConn(id, patch) {
    const st = load();
    const c = (st.conns || []).find((x) => x.id === id);
    if (!c) return null;
    Object.assign(c, patch);
    save(st);
    return c;
  }

  // Budget accounting, reset on date change.
  function spendRoom(c) {
    const today = new Date().toISOString().slice(0, 10);
    const spent = c.spentDate === today ? (c.spentToday || 0) : 0;
    return { today, spent, left: Math.max(0, (c.dailySat || 0) - spent) };
  }
  function recordSpend(c, sat) {
    const { today, spent } = spendRoom(c);
    updateConn(c.id, { spentDate: today, spentToday: spent + sat, lastUsed: Date.now() });
    // Publish the snapshot: budgets only bind across devices if the spend
    // travels — and the payment just changed the ark state (vtxos, movement)
    // that the user's other devices display.
    try { wallet.saveCache(); } catch {}
    writeBg(); // keep the worker's mirror as fresh as the state it spends
  }

  // The relays advertised in the URI MUST be the ones we listen on, or a
  // client will publish its request somewhere we aren't and wait forever.
  // Deliberately independent of the device-sync relay setting.
  const NWC_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
  const relays = () => NWC_RELAYS;

  // The string the user pastes into another app.
  function uriFor(c) {
    const rs = relays().map((r) => `relay=${encodeURIComponent(r)}`).join('&');
    return `nostr+walletconnect://${c.servicePk}?${rs}&secret=${c.secret}`;
  }

  function createConn({ name, maxSat, dailySat }) {
    const st = load();
    st.conns = st.conns || [];
    // A dedicated service key per connection: revoking one can't affect the
    // others, and none of them is the user's own nostr identity.
    const serviceSk = generateSecretKey();
    const clientSk = generateSecretKey();
    const c = {
      id: hex.encode(sha256(new Uint8Array([...serviceSk, ...clientSk]))).slice(0, 12),
      name: name || 'App',
      serviceSk: hex.encode(serviceSk),
      servicePk: getPublicKey(serviceSk),
      secret: hex.encode(clientSk),
      clientPk: getPublicKey(clientSk),
      maxSat: maxSat || 10000,
      dailySat: dailySat || 50000,
      spentToday: 0,
      spentDate: null,
      created: Date.now(),
      revoked: false,
    };
    st.conns.push(c);
    save(st);
    try { wallet.saveCache(); } catch {} // share it with the user's other devices
    publishInfo(c).catch(() => {});
    listen();
    refreshRegistration(); // the notifier must learn the new service pubkey
    // Arm background answering off the same click — creating a connection is
    // the one moment a permission prompt is both allowed and expected.
    ensureBackground(true);
    return c;
  }

  // Connections ride the encrypted device-sync snapshot, so a connection made
  // on the phone can be answered by whichever device happens to be open. The
  // snapshot is nip44-encrypted to a key derived from the seed — the same
  // channel that already carries ark vtxo bytes and gift secrets — so the
  // service keys travel no less safely than the coins do.
  //
  // Merge is a union by id and REVOCATION WINS: a connection revoked on one
  // device must never be resurrected by an older snapshot from another.
  function mergeConns(a = [], b = []) {
    const by = new Map();
    for (const c of [...a, ...b]) {
      const prev = by.get(c.id);
      if (!prev) { by.set(c.id, { ...c }); continue; }
      by.set(c.id, {
        ...prev, ...c,
        revoked: !!(prev.revoked || c.revoked),
        // keep the highest spend seen for the day so a stale device can't
        // hand a connection back its budget
        spentDate: (c.spentDate === prev.spentDate) ? c.spentDate : (c.spentDate || prev.spentDate),
        spentToday: (c.spentDate === prev.spentDate)
          ? Math.max(prev.spentToday || 0, c.spentToday || 0)
          : (c.spentToday || prev.spentToday || 0),
        lastUsed: Math.max(prev.lastUsed || 0, c.lastUsed || 0),
      });
    }
    return [...by.values()];
  }

  // sync is an optional feature: a minimal build has no cache extensions
  if (wallet.registerCacheExtension) wallet.registerCacheExtension({
    domain: 'nwc', // published as its own sync slot — see splitSnapshotDomains
    mergeAlways: true, // the merge is commutative, so older snapshots are fine
    save: () => {
      const st = load();
      const out = {};
      if ((st.conns || []).length) out.nwcConns = st.conns;
      if (st.offer) out.nwcOffer = st.offer;
      return out;
    },
    load: (d) => {
      // the offer keypair: whichever device minted one first wins, so every
      // device answers for the SAME offer code
      if (d.nwcOffer && d.nwcOffer.sk) {
        const st0 = load();
        if (!st0.offer || (d.nwcOffer.created || 0) < (st0.offer.created || Infinity)) {
          st0.offer = d.nwcOffer;
          save(st0);
        }
      }
      if (!Array.isArray(d.nwcConns)) return;
      const st = load();
      const merged = mergeConns(st.conns || [], d.nwcConns);
      // only write (and re-listen) when something actually changed
      if (JSON.stringify(merged) === JSON.stringify(st.conns || [])) return;
      st.conns = merged;
      save(st);
      listen();
      refreshRegistration(); // connection set changed under us via sync
      render();
    },
  });

  // ---- protocol ---------------------------------------------------------

  async function publishInfo(c) {
    const sk = hex.decode(c.serviceSk);
    const evt = finalizeEvent({
      kind: INFO_KIND,
      created_at: nowSec(),
      tags: [['p', c.clientPk], ['encryption', 'nip44_v2 nip04']],
      content: METHODS.join(' '),
    }, sk);
    return publish(NWC_RELAYS, evt);
  }

  const decrypt = (scheme, sk, pk, ct) => (scheme === 'nip44_v2'
    ? nip44.decrypt(ct, nip44.getConversationKey(sk, pk))
    : nip04.decrypt(sk, pk, ct));
  const encrypt = (scheme, sk, pk, txt) => (scheme === 'nip44_v2'
    ? nip44.encrypt(txt, nip44.getConversationKey(sk, pk))
    : nip04.encrypt(sk, pk, txt));

  // A pay answered by a hidden tab is as invisible as one paid by the
  // worker — surface it the same way. A visible tab already shows it live.
  async function notifySent(c, amountSat, feeSat) {
    try {
      if (load().paySentNotify === false) return;
      if (typeof document === 'undefined' || !document.hidden) return;
      if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
      const reg = await navigator.serviceWorker.ready;
      await reg.showNotification(t('nwcPaidTitle'), {
        body: t('nwcPaidBody', { amount: amountSat.toLocaleString(), name: c.name || 'app' })
          + (feeSat ? ` (+${feeSat} fee)` : ''),
        icon: 'icon-192.png', badge: 'badge-96.png',
        tag: 'nwc-sent', renotify: true, data: { url: './' },
      });
    } catch {}
  }

  // Every request gets a reply, including failures — a client that never
  // hears back just hangs.
  async function reply(c, ev, scheme, payload) {
    const sk = hex.decode(c.serviceSk);
    try {
      const content = await encrypt(scheme, sk, ev.pubkey, JSON.stringify(payload));
      const evt = finalizeEvent({
        kind: RES_KIND,
        created_at: nowSec(),
        tags: [['p', ev.pubkey], ['e', ev.id], ['encryption', scheme]],
        content,
      }, sk);
      await publish(NWC_RELAYS, evt);
      noteAnswered(); // a woken background run can stop once this happens
    } catch (e) {
      console.warn('nwc: could not reply', e.message);
    }
  }

  const handled = new Set(); // request ids seen this session (replay guard)
  let lastError = null;      // surfaced in the Settings card
  let lastSeen = null;       // last request we accepted, ditto

  async function onRequest(c, ev) {
    // TEMP instrumentation for the Amethyst-spinner bug: every event that
    // reaches this handler logs, and every silent guard names itself.
    dlog(`nwc: onRequest ev=${ev.id.slice(0, 8)} kind=${ev.kind} age=${nowSec() - ev.created_at}s from=${ev.pubkey.slice(0, 8)} conn=${c.id}`);
    if (ev.kind !== REQ_KIND) return dlog('nwc: guard drop — wrong kind');
    if (handled.has(ev.id)) return dlog('nwc: guard drop — already handled', ev.id.slice(0, 8));
    handled.add(ev.id);
    if (handled.size > 500) handled.clear();
    if (ev.created_at && nowSec() - ev.created_at > MAX_AGE_SEC) {
      return dlog('nwc: guard drop — too old', nowSec() - ev.created_at, 's');
    }
    // Same wallet, several tabs: the first to claim the request answers it.
    return withRequestLock(ev.id, () => serve(c, ev),
      () => dlog('nwc: skip — another tab or the worker holds', ev.id.slice(0, 8)));
  }

  async function serve(c, ev) {
    // only the client this connection was issued to
    if (ev.pubkey !== c.clientPk) {
      return dlog(`nwc: guard drop — pubkey mismatch got=${ev.pubkey.slice(0, 8)} want=${c.clientPk.slice(0, 8)}`);
    }
    // Re-resolve the connection from stored state: the closure's `c` is a
    // snapshot from listen() time, so budget math computed from it restarts
    // from a stale value on every request (spends never accumulate), and a
    // connection revoked on another device would keep being served here.
    const cur = (load().conns || []).find((x) => x.id === c.id);
    if (!cur || cur.revoked) return dlog('nwc: guard drop — connection gone or revoked', c.id);
    c = cur;

    const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2'
      ? 'nip44_v2' : 'nip04';
    let req;
    try {
      req = JSON.parse(await decrypt(scheme, hex.decode(c.serviceSk), ev.pubkey, ev.content));
    } catch (e) {
      // This used to return silently, on the theory that undecryptable traffic
      // simply wasn't for us. But the event is addressed to this connection's
      // service key and signed by its client key — it IS for us, so a failure
      // here is a bug in our own handling and must be loud. Silently dropping
      // it looks identical to the wallet being offline.
      const why = `${scheme} decrypt failed: ${e && e.message ? e.message : e}`;
      console.error('nwc:', why, { ev: ev.id, scheme, from: ev.pubkey.slice(0, 12) });
      lastError = { at: Date.now(), why, scheme, evId: ev.id };
      // Reply so the client fails fast instead of spinning forever. Try the
      // other scheme for the reply in case our scheme detection was wrong.
      await reply(c, ev, scheme, { result_type: 'error', ...errRes('INTERNAL', why) })
        .catch(() => {});
      if (scheme === 'nip04') {
        await reply(c, ev, 'nip44_v2', { result_type: 'error', ...errRes('INTERNAL', why) })
          .catch(() => {});
      }
      return;
    }
    const { method, params = {} } = req || {};
    if (!METHODS.includes(method)) {
      return reply(c, ev, scheme, { result_type: method, ...errRes('NOT_IMPLEMENTED', 'method not supported') });
    }
    // Leader election across the user's open devices: every tab sees every
    // request. The notifier's claim decides in one round trip; only when it
    // gives no verdict do we fall back to the old dance — defer a beat, then
    // stay SILENT if any reply for this request already exists on the relays
    // (a late tab, woken with the request still inside the replay window,
    // once re-answered zaps another device had paid). A request older than a
    // few seconds is a replay or a wake-up: check the relays first, since a
    // reply very likely exists and the claim may have expired with it.
    let claimed = null; // this device's verdict, resolved before any spend
    if (method === 'pay_invoice') {
      const age = nowSec() - (ev.created_at || 0);
      const lookBack = async () => {
        const prior = await query(NWC_RELAYS, { kinds: [RES_KIND], '#e': [ev.id] }, 1500);
        return !!(prior && prior.length);
      };
      if (age > 5 && await lookBack()) return dlog('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8));
      if (mobile && age <= 5) await new Promise((r) => setTimeout(r, 1000));
      claimed = await claim(ev.id);
      if (claimed === false) return dlog('nwc: standing down — another device claimed', ev.id.slice(0, 8));
      if (claimed === null && age <= 5) {
        await new Promise((r) => setTimeout(r, 300 + Math.random() * 900));
        if (await lookBack()) return dlog('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8));
      }
    }
    updateConn(c.id, { lastUsed: Date.now() });
    lastSeen = { at: Date.now(), method, scheme };

    // A failed pay_invoice on THIS device must not shout over another
    // device's success: two tabs can pass the pre-check together, and the
    // one that loses (stale state, mid-init, refused vtxo) would otherwise
    // reply an error AFTER the winner's preimage, which some clients trust.
    // The same hold covers a success learned from a sibling's payment: the
    // device that paid owns the reply, this one only fills in if it never
    // comes.
    const sendChecked = async (payload) => {
      if (method === 'pay_invoice' && (payload.error || payload.sibling)) {
        await new Promise((r) => setTimeout(r, errGraceMs));
        const prior = await query(NWC_RELAYS, { kinds: [RES_KIND], '#e': [ev.id] }, 1500).catch(() => []);
        if (prior && prior.length) {
          return dlog('nwc: staying quiet — request answered elsewhere', ev.id.slice(0, 8));
        }
      }
      delete payload.sibling;
      await reply(c, ev, scheme, payload);
    };
    try {
      const out = await handle(c, method, params);
      await sendChecked({ result_type: method, ...out });
    } catch (e) {
      await sendChecked({ result_type: method, ...errRes('INTERNAL', e.message || 'failed') });
    }
    render();
  }

  async function handle(c, method, params) {
    if (!hook('arkReady')) return errRes('UNAUTHORIZED', 'Ark is not configured in this wallet');

    if (method === 'get_info') {
      return {
        result: {
          alias: 'Coinos', color: '#15171a', network: getNetwork() === 'mainnet' ? 'mainnet' : getNetwork(),
          block_height: 0, block_hash: '', methods: METHODS,
        },
      };
    }

    if (method === 'get_balance') {
      // NIP-47 reports millisats
      return { result: { balance: (hook('arkSpendableSat') || 0) * 1000 } };
    }

    if (method === 'pay_invoice') {
      const invoice = params.invoice;
      if (!invoice) return errRes('OTHER', 'missing invoice');
      // Enforce limits BEFORE spending anything — an over-budget request must
      // be refused, not paid and then regretted.
      const dec = maybeBolt11(invoice);
      if (!dec) return errRes('OTHER', 'not a bolt11 invoice');
      if (!dec.amountSat) return errRes('OTHER', 'zero-amount invoices are not supported');
      // The invoice names its network. A wallet on another network (a
      // mutinynet tab, say) must not race the user's real payer with an
      // instant mismatch error — hold back long enough for the right device
      // to answer; sendChecked then suppresses this if anyone did.
      const netOf = (n) => (n === 'mutinynet' ? 'signet' : n);
      if (dec.network && netOf(getNetwork()) !== dec.network) {
        return errRes('INTERNAL', `invoice is for ${dec.network}, wallet is on ${netOf(getNetwork())}`);
      }
      const { left } = spendRoom(c);
      if (dec.amountSat > c.maxSat) {
        return errRes('QUOTA_EXCEEDED', `over the ${c.maxSat} sat per-payment limit`);
      }
      if (dec.amountSat > left) {
        return errRes('QUOTA_EXCEEDED', `over the remaining daily budget (${left} sat)`);
      }
      let res;
      try {
        res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat });
      } catch (e) {
        // The ASP already holds a payment for this invoice: another device
        // of this wallet took the same request first. Its outcome is ours —
        // wait for it rather than report a failure over a zap that is
        // paying. The payer records the spend; this side only relays.
        if (!/already in progress/i.test(e.message || '')) throw e;
        const out = await hook('arkLnOutcome', dec.paymentHash, 60000);
        if (!out || out.status !== 'success') throw e;
        return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } };
      }
      // the ark seam throws on failure, so reaching here means it settled
      recordSpend(c, (res.amountSat || 0) + (res.feeSat || 0));
      notifySent(c, res.amountSat || 0, res.feeSat || 0);
      return { result: { preimage: res.preimage, fees_paid: (res.feeSat || 0) * 1000 } };
    }

    if (method === 'make_invoice') {
      const sat = Math.floor((params.amount || 0) / 1000);
      if (!sat) return errRes('OTHER', 'amount required (msat)');
      const inv = await hook('arkMakeInvoice', sat, params.description || '');
      return {
        result: {
          type: 'incoming', state: 'pending', invoice: inv.invoice, payment_hash: inv.paymentHash,
          amount: sat * 1000, created_at: nowSec(), description: params.description || '',
          expires_at: inv.expiresAt ? Math.floor(inv.expiresAt / 1000) : undefined,
        },
      };
    }

    if (method === 'lookup_invoice') {
      if (!params.payment_hash && !params.invoice) return errRes('OTHER', 'payment_hash or invoice required');
      const tx = await hook('arkLnLookup', { paymentHash: params.payment_hash, invoice: params.invoice });
      return tx ? { result: tx } : errRes('NOT_FOUND', 'invoice not found');
    }

    if (method === 'list_transactions') {
      const movements = hook('arkMovements') || [];
      const txs = movements
        .filter((m) => ['ln-send', 'ln-receive'].includes(m.type) && m.status === 'complete')
        .slice(-(params.limit || 20))
        .map((m) => ({
          type: m.type === 'ln-receive' ? 'incoming' : 'outgoing',
          state: 'settled', invoice: m.invoice || '', preimage: m.preimage || '',
          amount: (m.amountSat || 0) * 1000, fees_paid: 0,
          created_at: Math.floor((m.ts || Date.now()) / 1000),
          settled_at: Math.floor((m.ts || Date.now()) / 1000),
          description: m.detail || '',
        }));
      return { result: { transactions: txs } };
    }

    return errRes('NOT_IMPLEMENTED', 'method not supported');
  }

  // ---- CLINK offer (a static, zappable payment code) ---------------------
  // One offer keypair per wallet, synced across devices like connections.
  // Anyone with the code can request an invoice (kind 21001); every open tab
  // answers, and the service worker answers while closed. Amounts are minted
  // straight against the wallet's own balance — nothing custodial.
  const OFFER_KIND = 21001;
  function offerKeys() {
    const st = load();
    if (st.offer) return st.offer;
    const sk = generateSecretKey();
    st.offer = { sk: hex.encode(sk), pk: getPublicKey(sk), created: Date.now() };
    save(st);
    try { wallet.saveCache(); } catch {}
    return st.offer;
  }
  const offerString = () => {
    const o = offerKeys();
    return encodeNoffer({ pubkey: o.pk, relay: NWC_RELAYS[0], offerId: 'zap_default', priceType: 2 });
  };

  async function onOfferRequest(ev) {
    if (ev.kind !== OFFER_KIND) return;
    if (handled.has(ev.id)) return;
    handled.add(ev.id);
    if (ev.created_at && nowSec() - ev.created_at > MAX_AGE_SEC) return;
    return withRequestLock(ev.id, () => serveOffer(ev));
  }

  async function serveOffer(ev) {
    const o = load().offer;
    if (!o) return;
    const sk = hex.decode(o.sk);
    let req;
    try { req = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey))); } catch { return; }
    const sendBack = async (payload) => {
      const content = nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey));
      const evt = finalizeEvent({
        kind: OFFER_KIND, created_at: nowSec(),
        tags: [['p', ev.pubkey], ['e', ev.id], ['clink_version', '1']],
        content,
      }, sk);
      await publish(NWC_RELAYS, evt);
    };
    // multi-device: stay silent if another device answered already
    const prior = await query(NWC_RELAYS, { kinds: [OFFER_KIND], '#e': [ev.id] }, 1200).catch(() => []);
    if (prior && prior.length) return;
    const sat = Math.floor(req.amount_sats || 0);
    if (!sat || sat < 1) return sendBack({ error: 'Invalid Amount', code: 5, range: { min: 1, max: 1000000 } });
    try {
      const inv = await hook('arkMakeInvoice', sat, (req.description || '').slice(0, 100));
      if (req.zap) {
        const st = load();
        st.zapPending = [...(st.zapPending || []), { zap: req.zap, invoice: inv.invoice, ts: Date.now() }].slice(-50);
        save(st);
      }
      await sendBack({ bolt11: inv.invoice });
      lastSeen = { at: Date.now(), method: 'offer', scheme: 'nip44_v2' };
      render();
    } catch (e) {
      console.warn('nwc: offer mint failed', e.message);
      await sendBack({ error: 'Temporary Failure', code: 2 }).catch(() => {});
    }
  }

  // Zap receipts: once a pending zap's invoice is actually received (the ark
  // movement completes), publish the kind 9735 receipt signed by the offer
  // key — that's what makes the zap show up under the note.
  async function publishZapReceipts() {
    const st = load();
    const o = st.offer;
    let pend = st.zapPending || [];
    // the worker stashes its pending zaps next to the mirror — adopt them
    try {
      const rec = await loadBg(wallet._cacheKey());
      if (rec && (rec.zapPending || []).length) {
        pend = [...pend, ...rec.zapPending].slice(-50);
        rec.zapPending = [];
        await saveBg(wallet._cacheKey(), rec);
      }
    } catch {}
    if (!o || !pend.length) return;
    const moves = hook('arkMovements') || [];
    const keep = [];
    for (const z of pend) {
      if (Date.now() - z.ts > 86400_000) continue; // expired unpaid
      const paid = moves.find((m) => m.type === 'ln-receive' && m.status === 'complete' && m.invoice === z.invoice);
      if (!paid) { keep.push(z); continue; }
      try {
        const zapReq = JSON.parse(z.zap);
        const tags = [
          ...zapReq.tags.filter((t2) => t2[0] === 'p' || t2[0] === 'e'),
          ['P', zapReq.pubkey],
          ['bolt11', z.invoice],
          ['description', z.zap],
        ];
        if (paid.preimage) tags.push(['preimage', paid.preimage]);
        const receipt = finalizeEvent({ kind: 9735, created_at: nowSec(), tags, content: '' }, hex.decode(o.sk));
        const relayTag = zapReq.tags.find((t2) => t2[0] === 'relays');
        const relays = [...new Set([...(relayTag ? relayTag.slice(1) : []), ...NWC_RELAYS])].slice(0, 8);
        await publish(relays, receipt);
      } catch (e) { console.warn('nwc: zap receipt failed', e.message); }
    }
    const st2 = load();
    st2.zapPending = keep;
    save(st2);
  }

  // ---- lifecycle --------------------------------------------------------

  let unsubs = [];
  const infoPublished = new Set(); // connection ids whose 13194 went out this session
  let listenGen = 0; // TEMP instrumentation: which listen() generation is live
  function stop() {
    if (unsubs.length) dlog(`nwc: stop() closing ${unsubs.length} sub(s) from gen ${listenGen}`);
    for (const u of unsubs) { try { u(); } catch {} }
    unsubs = [];
  }
  function listen() {
    stop();
    listenGen++;
    const gen = listenGen;
    if (wallet.watchOnly) return;
    // the offer subscription rides the same lifecycle as connections
    if (hook('arkReady')) {
      const o = offerKeys();
      unsubs.push(subscribe(
        NWC_RELAYS,
        { kinds: [OFFER_KIND], '#p': [o.pk], since: nowSec() - MAX_AGE_SEC },
        (ev) => { onOfferRequest(ev).catch((e) => console.warn('nwc: offer handler', e.message)); },
      ));
    }
    const list = conns();
    dlog(`nwc: listen() gen ${gen} — ${list.length} connection(s)`);
    for (const c of list) {
      dlog(`nwc: gen ${gen} subscribing for conn=${c.id} service=${c.servicePk.slice(0, 8)}`);
      unsubs.push(subscribe(
        NWC_RELAYS,
        { kinds: [REQ_KIND], '#p': [c.servicePk], since: nowSec() - MAX_AGE_SEC },
        (ev) => {
          dlog(`nwc: gen ${gen} event arrived ev=${ev.id.slice(0, 8)} for conn=${c.id}`);
          onRequest(c, ev).catch((e) => console.error('nwc: onRequest threw', e));
        },
        { onclose: (reasons) => dlog(`nwc: gen ${gen} sub CLOSED for conn=${c.id}:`, JSON.stringify(reasons)) },
      ));
      // Republish the capability event once per session per connection: a
      // client that can't find kind 13194 just spins, and a single publish at
      // creation time can easily have been missed by a relay. But listen()
      // re-runs on every sync snapshot and wake, and relays rate-limit
      // accepted events per IP — repeated republishing eats the budget the
      // replies need (and once got this IP temp-banned).
      if (!infoPublished.has(c.id)) {
        infoPublished.add(c.id);
        publishInfo(c).catch(() => {});
      }
    }
  }

  // ---- background wake-ups ----------------------------------------------
  // The notifier can't decrypt anything; it only learns which service pubkeys
  // to watch. When a request arrives it pushes, the service worker surfaces
  // it (or nudges an open window), and coinos answers from the relay.
  const NOTIFIER = 'https://nwcpush.coinos.io';

  const b64ToBytes = (b64) => {
    const pad = '='.repeat((4 - (b64.length % 4)) % 4);
    const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
    return Uint8Array.from(raw, (c) => c.charCodeAt(0));
  };

  // Waking a closed app needs a push service, and on Android that means the
  // browser's: Chrome (and every Chromium, including GrapheneOS's Vanadium)
  // routes web push through Firebase, which isn't there on a phone without
  // Google Play Services. The browser says so as "Registration failed - push
  // service error", which tells the user nothing. Translate it, and name the
  // thing that does work.
  const isNoPushService = (e) => {
    const m = (e && (e.message || e.name)) || '';
    return /push service|Registration failed|AbortError|NotSupportedError|permission denied/i.test(m);
  };

  // The Android wrapper's UnifiedPush endpoint, if it handed us one (see
  // messages.js, which reads it off the launch URL). It needs no permission
  // and no push service, and on a phone without Google Play Services it is
  // the only way to be reached at all.
  const upEndpoint = () => { try { return localStorage.getItem('coinos-unifiedpush') || null; } catch { return null; } };

  async function enableBackground() {
    const up = upEndpoint();
    if (up) {
      const pksUp = conns().map((c) => c.servicePk);
      if (load().offer) pksUp.push(load().offer.pk);
      if (pksUp.length) {
        const r = await fetch(`${NOTIFIER}/register`, {
          method: 'POST', headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ subscription: { endpoint: up, unifiedpush: true }, servicePubkeys: pksUp }),
        });
        if (!r.ok) throw new Error(`notifier refused: ${r.status}`);
      }
      const stUp = load(); stUp.background = true; delete stUp.pushUnavailable; save(stUp);
      writeBg();
      return true;
    }
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
      throw new Error(t('nwcNoPushHere'));
    }
    const perm = await Notification.requestPermission();
    if (perm !== 'granted') throw new Error(t('nwcNoPermission'));
    const reg = await navigator.serviceWorker.ready;
    const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json();
    let sub;
    try {
      sub = await reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: b64ToBytes(publicKey),
      });
    } catch (e) {
      if (isNoPushService(e)) {
        const st2 = load(); st2.pushUnavailable = true; save(st2);
        throw new Error(t('nwcNoPushService'));
      }
      throw e;
    }
    { const st2 = load(); if (st2.pushUnavailable) { delete st2.pushUnavailable; save(st2); } }
    const pks1 = conns().map((c) => c.servicePk);
    if (load().offer) pks1.push(load().offer.pk);
    // Arming can precede the first connection (creation requires it) — the
    // notifier rejects an empty watch list, so defer that POST to the
    // refreshRegistration() the creation triggers moments later.
    if (pks1.length) {
      const r = await fetch(`${NOTIFIER}/register`, {
        method: 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: pks1 }),
      });
      if (!r.ok) throw new Error(`notifier refused: ${r.status}`);
    }
    const st = load(); st.background = true; save(st);
    writeBg();
    return true;
  }

  // Background answering should be the norm, not a setting the user has to
  // find: try to arm it whenever a connection exists. `interactive` marks a
  // call made from a user gesture (creating a connection), where the browser
  // allows the permission prompt; elsewhere we only proceed when permission
  // was already granted, so the user is never nagged. An explicit Off on the
  // toggle (backgroundOff) is final — auto-arming never overrides it.
  async function ensureBackground(interactive) {
    const st = load();
    if (st.background || st.backgroundOff) return;
    if (!conns().length) return;
    if (!upEndpoint()) {
      if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)
        || typeof window === 'undefined' || !('PushManager' in window)
        || typeof Notification === 'undefined') return;
      if (!interactive && Notification.permission !== 'granted') return;
    }
    try {
      await enableBackground();
      await reconcileBg();
      render();
    } catch (e) { dlog('nwc: background answering not armed:', e.message); }
  }

  // A connection that only answers while a tab happens to be open is a broken
  // promise — creating one REQUIRES the background path. Throws a
  // user-readable reason when it can't be armed; the caller shows it and
  // does not create the connection.
  async function requireBackground() {
    if (load().background) return;
    if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)
      || typeof window === 'undefined' || !('PushManager' in window)
      || typeof Notification === 'undefined') throw new Error(t('nwcNoPush'));
    if (Notification.permission === 'denied') throw new Error(t('nwcNotifBlocked'));
    try {
      await enableBackground();
    } catch (e) {
      // Denied at the prompt (or dismissed it): that's the user's call and
      // the connection is refused. But permission GRANTED with the plumbing
      // failing (notifier down, a self-hosted build with no notifier at all)
      // must not hold the connection hostage — it still answers from open
      // tabs, and ensureBackground keeps retrying the arming on every start.
      if (Notification.permission !== 'granted') throw new Error(t('nwcNotifNeeded'));
      console.warn('nwc: background arming failed, creating the connection anyway:', e.message);
    }
    const s = load(); delete s.backgroundOff; save(s);
  }

  // Keep the notifier's view of our service pubkeys current. It can only
  // wake this device for connections it was told about, and a registration
  // made before a connection existed (or after one was revoked) watches the
  // wrong keys — background wake-ups silently die the first time a
  // connection is recreated. Debounced; the server replaces by endpoint.
  let regTimer = null;
  function refreshRegistration() {
    if (!load().background) return;
    if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
    clearTimeout(regTimer);
    regTimer = setTimeout(async () => {
      try {
        const pks = conns().map((c) => c.servicePk);
        if (load().offer) pks.push(load().offer.pk);
        if (!pks.length) return;
        const up2 = upEndpoint();
        const sub = up2 ? { endpoint: up2, unifiedpush: true }
          : await (await navigator.serviceWorker.ready).pushManager.getSubscription().then((x) => x && x.toJSON());
        if (!sub) return;
        await fetch(`${NOTIFIER}/register`, {
          method: 'POST', headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ subscription: sub, servicePubkeys: pks }),
        });
      } catch (e) { console.warn('nwc: notifier re-register failed', e.message); }
    }, 1500);
  }

  async function disableBackground() {
    try {
      const reg = await navigator.serviceWorker.ready;
      const sub = await reg.pushManager.getSubscription();
      if (sub) {
        await fetch(`${NOTIFIER}/register`, {
          method: 'DELETE', headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ endpoint: sub.endpoint }),
        }).catch(() => {});
        await sub.unsubscribe().catch(() => {});
      }
    } catch {}
    const st = load(); st.background = false; save(st);
    // no background answering means no reason to keep spendable keys where a
    // worker can read them
    try { await clearBg(wallet._cacheKey()); } catch {}
  }

  // (Re)write the background record the worker answers from: the full ark
  // state mirror plus this feature's connections. The ark feature owns the
  // heavy lifting; this decides WHEN (background answering on).
  let bgTimer = null;
  function writeBg() {
    if (!load().background) return;
    clearTimeout(bgTimer);
    bgTimer = setTimeout(async () => {
      try {
        if (!hook('arkBgReady')) return;
        await hook('arkBgWrite', conns(), load().offer || null,
          { paySent: load().paySentNotify !== false });
      } catch (e) { console.warn('nwc: could not write background state', e.message); }
    }, 1500);
  }

  // When the app opens after the worker has been answering: absorb its spends
  // into the budgets and history. (The ark feature separately merges the
  // worker's coin state on connect.)
  async function reconcileBg() {
    try {
      if (!load().background) return;
      const rec = await loadBg(wallet._cacheKey());
      if (!rec) return;
      const fresh = (rec.spends || []).filter((s) => !s.absorbed);
      if (fresh.length) {
        for (const s of fresh) {
          const c = conns().find((x) => x.id === s.connId);
          if (c) recordSpend(c, (s.amountSat || 0) + (s.feeSat || 0));
          s.absorbed = true;
        }
        await hook('arkBgNoteSpends', fresh);
        await saveBg(wallet._cacheKey(), rec);
      }
      writeBg(); // refresh the mirror after the merge settled
      publishZapReceipts().catch(() => {});
      render();
    } catch (e) { console.warn('nwc: background reconcile failed', e.message); }
  }

  // An open window handles the request itself; the worker just nudges it.
  if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
    navigator.serviceWorker.addEventListener('message', (ev) => {
      if (ev.data?.type === 'nwc-wake') listen();
    });
  }

  // ---- woken by the Android wrapper --------------------------------------
  // The WebView build starts the wallet off-screen when a push arrives, with
  // ?wake=<payload> on the URL. There's no service worker in that picture —
  // the page IS the background — so it does the normal thing (subscribe, let
  // the request arrive, answer it) and then says it's finished, so the
  // service can stop rather than sit there costing battery. A cap, because
  // "finished" is not always reachable: the relay may have nothing for us.
  function serveWake() {
    const payload = ctx.wakePayload ? ctx.wakePayload() : null;
    if (payload == null) return;
    if (!ctx.wakeDone) return;
    let settled = false;
    const finish = () => { if (settled) return; settled = true; ctx.wakeDone(); };
    // No connections means nothing here could have been asked of us: end the
    // job now rather than hold a WebView open for half a minute on the
    // strength of a payload we can't act on.
    if (!conns().length) { finish(); return; }
    listen();          // the request is on a relay; this is what hears it
    reconcileBg();     // and make sure the pouch behind it is current
    // an answer published is the signal we were woken for
    const stop = onAnswered(() => setTimeout(finish, 1500));
    setTimeout(() => { try { stop(); } catch {} finish(); }, 25_000);
  }

  // Callers that want to know when this wallet answered something.
  const answeredWatchers = new Set();
  function onAnswered(fn) { answeredWatchers.add(fn); return () => answeredWatchers.delete(fn); }
  function noteAnswered() { for (const fn of [...answeredWatchers]) { try { fn(); } catch {} } }

  // Watchdog: relay sockets can die in ways the pool's reconnect never heals
  // (an errored socket sets skipReconnection and gives up), and a wallet
  // service with a dead subscription is indistinguishable from one that's
  // closed — clients just spin. Re-subscribing is cheap and duplicate
  // deliveries are absorbed by the replay guard, so re-listen on a timer and
  // whenever the network or the tab comes back.
  let watchdog = null;
  function startWatchdog() {
    if (watchdog) return;
    watchdog = setInterval(() => { listen(); writeBg(); publishZapReceipts().catch(() => {}); }, 90 * 1000);
    if (typeof window !== 'undefined') {
      window.addEventListener('online', () => listen());
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible' && conns().length) listen();
      });
    }
  }

  // ---- UI ---------------------------------------------------------------

  function nwcCard() {
    if (!hook('arkReady')) return null;
    const list = conns();
    const st = ui.nwcNew;
    return h('div', { class: 'card col' },
      h('h3', { class: 'row gap6', style: 'align-items:center' }, '🔌', t('nwcTitle')),
      h('p', { class: 'small muted', style: 'margin:0' }, t('nwcDesc')),
      h('div', { class: 'small faint' }, t('nwcTabWarning')),
      lastError
        ? h('div', { class: 'notice err', style: 'font-size:11px' },
            `${new Date(lastError.at).toLocaleTimeString()}: ${lastError.why}`)
        : null,
      lastSeen
        ? h('div', { class: 'small faint' },
            `last request: ${lastSeen.method} (${lastSeen.scheme}) at ${new Date(lastSeen.at).toLocaleTimeString()}`)
        : null,
      ...list.map((c) => {
        const { spent } = spendRoom(c);
        return h('div', { class: 'col', style: 'gap:4px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' },
          h('div', { class: 'row between' },
            h('span', {}, c.name),
            h('span', { class: 'linklike small', onClick: () => {
              updateConn(c.id, { revoked: true }); listen();
              refreshRegistration(); // stop waking this device for a dead key
              try { wallet.saveCache(); } catch {} // propagate the revocation
              render();
              toast(t('nwcRevoked', { name: c.name }));
            } }, t('nwcRevoke'))),
          h('div', { class: 'small faint' },
            t('nwcLimits', { max: fmtAmount(c.maxSat), day: fmtAmount(c.dailySat), spent: fmtAmount(spent) })),
          ui.nwcShow === c.id
            ? h('div', { class: 'col', style: 'gap:6px;align-items:center' },
                h('div', { html: qrSvg(uriFor(c)) }),
                h('div', { class: 'addr-box break', style: 'width:100%;font-size:10px' }, uriFor(c)),
                copyBtn(uriFor(c), t('nwcCopy')))
            : h('button', { class: 'btn-sm', onClick: () => { ui.nwcShow = c.id; render(); } }, t('nwcShow')));
      }),
      list.length
        ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' },
          load().pushUnavailable && !load().background
            ? h('div', { class: 'small faint' }, hook('pushAdvice') || t('nwcNoPushService'))
            : null,
          h('div', { class: 'row between' },
            h('span', { class: 'small' }, t('nwcBackground')),
            h('button', { class: 'btn-sm', onClick: async () => {
              try {
                if (load().background) {
                  await disableBackground();
                  // an explicit Off is a decision — auto-arming must not undo it
                  const s = load(); s.backgroundOff = true; save(s);
                  toast(t('nwcBackgroundOff'));
                } else {
                  // an explicit tap always retries: a phone that grew a push
                  // service (or a different browser) deserves another go
                  const s0 = load(); delete s0.pushUnavailable; save(s0);
                  await enableBackground();
                  const s = load(); delete s.backgroundOff; save(s);
                  await reconcileBg();
                  toast(t('nwcBackgroundOn'));
                }
              } catch (e) { toast(e.message); }
              render();
            } }, load().background ? t('nwcOn') : t('nwcOff'))))
        : null,
      st
        ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' },
            h('input', { type: 'text', placeholder: t('nwcAppName'), value: st.name,
              onInput: (e) => { st.name = e.target.value; } }),
            h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcMaxPayment')),
              h('input', { type: 'number', min: '1', value: st.maxSat,
                onInput: (e) => { st.maxSat = e.target.value; } })),
            h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcDailyBudget')),
              h('input', { type: 'number', min: '1', value: st.dailySat,
                onInput: (e) => { st.dailySat = e.target.value; } })),
            (typeof Notification === 'undefined' || Notification.permission !== 'granted')
              ? h('div', { class: 'small faint' }, t('nwcNotifHint'))
              : null,
            h('div', { class: 'row gap6' },
              h('button', { class: 'btn-ghost', onClick: () => { ui.nwcNew = null; render(); } }, t('cancel')),
              h('button', { class: 'btn-primary grow', onClick: async () => {
                // No notifications, no connection: it would sit dead the
                // moment the tab closes, spinning in the other app.
                try { await requireBackground(); }
                catch (e) { toast(e.message); return; }
                const c = createConn({
                  name: st.name, maxSat: parseInt(st.maxSat, 10) || 10000,
                  dailySat: parseInt(st.dailySat, 10) || 50000,
                });
                ui.nwcNew = null; ui.nwcShow = c.id; render();
              } }, t('nwcCreate'))))
        : h('button', { class: 'btn-ghost btn-block', onClick: () => {
            ui.nwcNew = { name: '', maxSat: '10000', dailySat: '50000' }; render();
          } }, t('nwcAdd')));
  }

  return {
    id: 'nwc',
    nwcOfferPubkey() { return (hook('arkReady') && !wallet.watchOnly) ? offerKeys().pk : null; },
    nwcOfferString() { return hook('arkReady') && !wallet.watchOnly ? offerString() : null; },
    init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); ensureBackground(false); serveWake(); },
    stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } },
    nostrSettingsCards() { return [nwcCard()]; },
    notifySettingsCards() {
      const on = load().paySentNotify !== false;
      return [h('div', { class: 'card col', style: 'gap:10px' },
        h('div', { class: 'row between' },
          h('span', { class: 'small' }, t('notifPaySent')),
          h('button', { class: 'btn-sm', onClick: () => {
            const s = load(); s.paySentNotify = !on; save(s); writeBg(); render();
          } }, on ? t('nwcOn') : t('nwcOff'))),
        h('div', { class: 'small faint' }, t('notifPaySentHint')))];
    },
  };
}