aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/ark.js')
0 files changed, 0 insertions, 0 deletions
' href='#n52'>52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
// Cross-device sync feature — encrypted wallet state on Nostr relays
// (NIP-44 to self, replaceable kind 30078) plus the wallet's nostr identity
// (used e.g. to DM locked-gift claim codes). Installed onto the core wallet;
// a build without sync ships none of it and never talks to a relay.

import {
  NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag, deviceDtag,
  domainDtag, isOurDtag, isCoreDtag, isOwnDeviceDtag,
  fetchNostrProfile, PROFILE_RELAYS, DEFAULT_SYNC_RELAYS, queryOn, publishOn,
  finalizeEvent,
} from '../nostr.js';
import { t } from '../i18n.js';

// Keep a published snapshot under the relay's event-size cap. relay.coinos.io
// rejects events over 64KB and nip44 expands the plaintext ~1.5x, so the JSON
// must stay under ~40KB. The on-chain tx history is the main unbounded growth;
// drop the oldest (this.txs is newest-first) — another device backfills the
// rest with a rescan. Only trims oversized snapshots, so typical wallets are
// untouched.
const RELAY_JSON_BUDGET = 40000;
function trimForRelay(snap) {
  if (JSON.stringify(snap).length <= RELAY_JSON_BUDGET) return snap;
  const s = { ...snap };
  let txs = (s.txs || []).slice();
  while (txs.length > 25 && JSON.stringify(s).length > RELAY_JSON_BUDGET) {
    txs = txs.slice(0, Math.max(25, txs.length - 25));
    s.txs = txs;
  }
  return s;
}

// Split a snapshot into per-domain pieces, each published as its own
// replaceable event: an extension registered with a `domain` claims its keys,
// everything left is the core wallet. Every piece carries netName + savedAt so
// readers can apply the same guards as for full snapshots. Small events scale
// on vanilla relays (no special size rules) and only changed domains
// republish, so a payment burst no longer re-uploads the whole wallet.
export function splitSnapshotDomains(snap, extensions = []) {
  const core = { ...snap };
  const out = {};
  for (const e of extensions) {
    if (!e.domain || !e.save) continue;
    let obj;
    try { obj = e.save(); } catch { continue; }
    const keys = Object.keys(obj || {});
    if (!keys.length) continue;
    out[e.domain] = { netName: snap.netName, savedAt: snap.savedAt, ...obj };
    for (const k of keys) delete core[k];
  }
  out.core = core;
  return out;
}

export function installSyncWallet(wallet) {
  if (wallet.syncFromNostr) return; // already installed
  wallet.nostr = new NostrSync();
  wallet._nostrPubTimer = null;

  Object.assign(wallet, {
    // Our nostr identity (used to DM a locked gift's claim code to the recipient).
    nostrPubkey() { return (this.nostr && this.nostr.pk) || null; },

    nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; },

    // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey.
    async sendNostrDM(recipientPkHex, text) {
      return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false;
    },

    // Generic event publish/fetch on the configured sync relays — the seam
    // other features (ark zaps) use to speak their own kinds. No-ops when
    // sync is disabled or the wallet is offline.
    async nostrPublish(partial) {
      const sync = getSyncConfig();
      if (this.offline || !sync.enabled || !this.nostr.sk) return null;
      this.nostr.setRelays(sync.relays);
      return this.nostr.publishEvent(partial);
    },
    // Publish an event signed by another key (the NWC wallet service keys).
    async nostrPublishSigned(evt) {
      const sync = getSyncConfig();
      if (this.offline || !sync.enabled) return null;
      this.nostr.setRelays(sync.relays);
      return this.nostr.publishSigned(evt);
    },
    async nostrFetch(filter, maxWait) {
      const sync = getSyncConfig();
      if (this.offline || !sync.enabled) return [];
      this.nostr.setRelays(sync.relays);
      return this.nostr.fetchEvents(filter, maxWait);
    },
    nostrSubscribe(filter, onEvent) {
      const sync = getSyncConfig();
      if (this.offline || !sync.enabled) return () => {};
      this.nostr.setRelays(sync.relays);
      return this.nostr.subscribeEvents(filter, onEvent);
    },

    // Sign an event with our nostr key without publishing (NIP-57 zap request).
    nostrSign(partial) { return this.nostr ? this.nostr.signEvent(partial) : null; },

    // Fetch a pubkey's profile (name/picture + lud16/lud06 for zaps) from the
    // broad profile relays — independent of whether cross-device sync is on.
    async nostrProfile(pkHex) {
      if (this.offline) return null;
      return fetchNostrProfile(pkHex);
    },

    // Relays to advertise in a zap request so we can later find the receipt:
    // our sync relays plus the broad profile relays.
    nostrRelays() {
      const sync = getSyncConfig();
      const base = sync.enabled ? sync.relays : [];
      return [...new Set([...base, ...PROFILE_RELAYS])].slice(0, 8);
    },

    // Pull the latest state from Nostr; apply it if it's newer than what we have.
    // Returns true if state was applied (so the caller can skip a full scan).
    async syncFromNostr() {
      const sync = getSyncConfig();
      if (this.offline || !sync.enabled) return false;
      this.nostr.setRelays(sync.relays);
      let all;
      try {
        all = await this.nostr.fetchAllStates();
      } catch {
        return false;
      }
      // Each device publishes to its OWN slot now, so there can be several
      // snapshots for this network. Keep only ours-for-this-network: the
      // netName inside is the real guard against cross-network bleed (legacy
      // shared-tag events are ambiguous), the d-tag check filters the rest.
      const mine = all.filter((s) => s.state.netName === this.netName && isOurDtag(s.dtag, this.netName));
      if (!mine.length) return false;
      // Merge every device's merge-safe extension state (ark vtxos union) — a
      // device sees ALL devices' coins, not only the newest snapshot's. Domain
      // fragments route themselves: an extension's load() only reads its keys.
      for (const s of mine) this._mergeSnapshotExtensions(s.state);
      // Apply the newest CORE-carrying snapshot for the rescannable on-chain
      // state. Domain fragments (ark/nwc slices) must never be applied as a
      // full snapshot — they'd blank the core fields they don't carry.
      const fulls = mine.filter((s) => isCoreDtag(s.dtag, this.netName));
      const newest = fulls[0] && fulls[0].state; // fetchAllStates returns newest-first
      if (newest && (newest.savedAt || 0) > (this._savedAt || 0)) {
        this._applySnapshot(newest); // re-runs extension loads (idempotent merge)
        this.emit();
      }
      this.saveCache(); // persist the merged result + republish our own slot
      return true;
    },
  });

  // identity follows the open wallet
  wallet.registerLoadHook(() => {
    if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase, wallet.accountIndex || 0);
    else wallet.nostr.unload();
    wallet._syncPubSeen = {}; // a new identity has published nothing yet
    adoptSyncRelays().catch(() => {});
  });

  // Sync rides the user's own relays: their NIP-65 (kind 10002) write set —
  // the login npub's first, else the wallet key's — falling back to the
  // coinos relay. A wallet key with no NIP-65 gets one published (default
  // relay, signed silently) so other clients can find this identity's events.
  async function adoptSyncRelays() {
    if (wallet.offline || !wallet.nostr.pk) return;
    const pks = [];
    try {
      const login = wallet.loadFeatureState('nostrlogin', {});
      if (login && /^[0-9a-f]{64}$/.test(login.pubkey || '')) pks.push(login.pubkey);
    } catch {}
    pks.push(wallet.nostr.pk);
    const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DEFAULT_SYNC_RELAYS])], { kinds: [10002], authors: pks }, 3500);
    const newestFor = (pk) => evs.filter((e) => e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
    const writeRelays = (e) => e.tags
      .filter((x) => x[0] === 'r' && x[1] && (!x[2] || x[2] === 'write'))
      .map((x) => x[1].trim()).filter((r) => /^wss?:\/\//.test(r)).slice(0, 4);
    for (const pk of pks) {
      const e = newestFor(pk);
      const relays = e && writeRelays(e);
      if (relays && relays.length) {
        setSyncConfig({ enabled: true, relays: [...new Set([...relays, ...DEFAULT_SYNC_RELAYS])].slice(0, 5) });
        break;
      }
    }
    // a brand-new wallet identity: announce its relays (NIP-65)
    if (wallet.nostr.sk && !newestFor(wallet.nostr.pk)) {
      const evt = finalizeEvent({
        kind: 10002, content: '',
        tags: DEFAULT_SYNC_RELAYS.map((r) => ['r', r]),
        created_at: Math.floor(Date.now() / 1000),
      }, wallet.nostr.sk);
      publishOn([...new Set([...PROFILE_RELAYS, ...DEFAULT_SYNC_RELAYS])], evt);
    }
  }
  // Live cross-device state: subscribe to ALL our devices' slots so a save on
  // another device merges here within seconds. Merge-safe extension state (ark
  // vtxos union) applies live; full snapshots stay a load-time affair. With
  // per-device slots no device overwrites another's, so this only ever adds.
  let stateUnsub = null;
  wallet.registerRealtimeHook({
    start: () => {
      const sync = getSyncConfig();
      if (!sync.enabled || !wallet.nostr.pk) return;
      wallet.nostr.setRelays(sync.relays);
      if (stateUnsub) { try { stateUnsub(); } catch {} }
      stateUnsub = wallet.nostr.subscribeStates((v, dtag) => {
        if (!v.netName || v.netName !== wallet.netName) return;
        if (!isOurDtag(dtag, wallet.netName)) return;
        if (isOwnDeviceDtag(dtag, wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo
        wallet._mergeSnapshotExtensions(v);
      });
    },
    stop: () => { if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } },
  });

  // push every saved snapshot to the relays (debounced) unless sync is off —
  // split into per-domain slots on THIS DEVICE's tags, publishing only the
  // domains whose content actually changed since the last publish.
  wallet._syncPubSeen = {}; // domain -> last published JSON (per identity)
  wallet.registerCacheSavedHook((snap) => {
    const sync = getSyncConfig();
    if (wallet.offline || !sync.enabled) return;
    wallet.nostr.setRelays(sync.relays);
    // bind the network now: the debounced publish must keep this snapshot's
    // network even if the wallet switches networks before the timer fires
    const net = wallet.netName;
    clearTimeout(wallet._nostrPubTimer);
    wallet._nostrPubTimer = setTimeout(async () => {
      const domains = splitSnapshotDomains(snap, wallet._cacheExtensions || []);
      domains.core = trimForRelay(domains.core);
      for (const [name, obj] of Object.entries(domains)) {
        const json = JSON.stringify(obj);
        const slot = `${net}:${name}`;
        if (wallet._syncPubSeen[slot] === json) continue; // unchanged
        const ok = await wallet.nostr.publish(obj, domainDtag(net, name));
        if (ok) wallet._syncPubSeen[slot] = json; // a refused publish retries next save
      }
    }, 2500);
  });
  wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) });
}

export function syncFeature(ctx) {
  const { h, ui, render, wallet } = ctx;
  installSyncWallet(wallet);

  return {
    id: 'sync',
  };
}