aboutsummaryrefslogtreecommitdiffstats
path: root/Dockerfile.base
blob: 9d2b17994ab73e47ecf6fba55a0ed64b5a34d45e (plain) (blame)
1
2
3
4
5
6
7
FROM node:18-alpine

RUN npm i -g pnpm

WORKDIR /deps
COPY package.json .
RUN NODE_ENV=development pnpm i
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',
  };
}