← Back
import { useState, useEffect, useCallback } from 'react';
import { usePrivy } from '@privy-io/react-auth';

/**
 * Copy-subscription management. Two modes:
 *  - logged OUT: localStorage demo (no money/auth), cross-page sync via event.
 *  - logged IN (Privy email): server-persisted per-user (/api/copy/subscriptions),
 *    identified by the embedded wallet address (X-User-Address header). On login,
 *    any localStorage demo subs migrate to the backend once, then localStorage is
 *    cleared and the backend becomes the source of truth. The copy-trader daemon
 *    reads these active subscriptions via /api/copy/active.
 */

export type AllocMode = 'percent' | 'fixed' | 'proportional';

export interface CopyConfig {
  allocMode: AllocMode;
  allocValue: number;
  maxPerTrade: number;
  maxExposure: number;
  categories: string[];
  priceMin: number;
  priceMax: number;
  mirrorExits: boolean;
  drawdownStop: number;
  // Counter-trading (chunk B). Optional for back-compat: absent ⇒ 'copy'.
  direction?: 'copy' | 'fade';
  fadeMin?: number;   // leader-price risk band (0..1) — don't fade outside it
  fadeMax?: number;
}

export interface CopySub {
  address: string;
  label: string;
  config: CopyConfig;
  status: 'active' | 'paused' | 'archived';
  createdAt: string;
}

const KEY = 'sz_copies';
const EVT = 'sz_copies_changed';

export const DEFAULT_CONFIG: CopyConfig = {
  allocMode: 'fixed',
  allocValue: 2,
  maxPerTrade: 5,
  maxExposure: 40,
  categories: [],
  priceMin: 5,
  priceMax: 60,
  mirrorExits: true,
  drawdownStop: 25,
  direction: 'copy',
  fadeMin: 0.20,
  fadeMax: 0.80,
};

function readLocal(): CopySub[] {
  try {
    const raw = localStorage.getItem(KEY);
    return raw ? JSON.parse(raw) : [];
  } catch { return []; }
}

function writeLocal(subs: CopySub[]) {
  try {
    localStorage.setItem(KEY, JSON.stringify(subs));
    window.dispatchEvent(new Event(EVT));
  } catch { /* ignore */ }
}

// Identify the user to the backend with the Privy access token (server verifies it
// against Privy's JWKS and derives the user identity from the signed claims — the
// client never asserts its own identity).
function authHeaders(token: string): Record<string, string> {
  return { 'content-type': 'application/json', authorization: `Bearer ${token}` };
}

async function fetchBackend(token: string): Promise<CopySub[]> {
  try {
    const r = await fetch('/api/copy/subscriptions', { credentials: 'include', headers: authHeaders(token) });
    if (!r.ok) return [];
    const d = await r.json();
    if (!d.success) return [];
    return (d.data as Array<{ leaderAddress: string; label?: string | null; config: CopyConfig; status: CopySub['status']; createdAt: string }>)
      .map(s => ({ address: s.leaderAddress, label: s.label ?? '', config: s.config, status: s.status, createdAt: s.createdAt }));
  } catch { return []; }
}

async function putBackend(token: string, leader: string, config: CopyConfig, status: CopySub['status']) {
  await fetch('/api/copy/subscriptions', {
    method: 'PUT',
    credentials: 'include',
    headers: authHeaders(token),
    body: JSON.stringify({ leader_address: leader, config, status }),
  });
}

export function useCopySubscriptions() {
  const { authenticated, getAccessToken } = usePrivy();
  const [subs, setSubs] = useState<CopySub[]>(() => (authenticated ? [] : readLocal()));

  const reload = useCallback(async () => {
    if (authenticated) { const t = await getAccessToken(); if (t) setSubs(await fetchBackend(t)); }
    else setSubs(readLocal());
  }, [authenticated, getAccessToken]);

  // On login: migrate localStorage demo subs to backend once, then clear local.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      if (authenticated) {
        const t = await getAccessToken();
        if (!t) return;
        const local = readLocal();
        for (const s of local) await putBackend(t, s.address, s.config, s.status);
        if (local.length) writeLocal([]);
        if (!cancelled) setSubs(await fetchBackend(t));
      } else {
        if (!cancelled) setSubs(readLocal());
      }
    })();
    const onChange = () => { if (!authenticated) setSubs(readLocal()); };
    window.addEventListener(EVT, onChange);
    window.addEventListener('storage', onChange);
    return () => { cancelled = true; window.removeEventListener(EVT, onChange); window.removeEventListener('storage', onChange); };
  }, [authenticated, getAccessToken]);

  const upsert = useCallback(async (leader: string, label: string, config: CopyConfig) => {
    if (authenticated) {
      const t = await getAccessToken(); if (!t) return;
      await putBackend(t, leader, config, 'active');
      await reload();
    } else {
      const list = readLocal();
      const i = list.findIndex(s => s.address.toLowerCase() === leader.toLowerCase());
      if (i >= 0) list[i] = { ...list[i], label, config };
      else list.push({ address: leader, label, config, status: 'active', createdAt: new Date().toISOString() });
      writeLocal(list);
    }
  }, [authenticated, getAccessToken, reload]);

  const remove = useCallback(async (leader: string) => {
    if (authenticated) {
      const t = await getAccessToken(); if (!t) return;
      await fetch(`/api/copy/subscriptions/${leader.toLowerCase()}`, { method: 'DELETE', credentials: 'include', headers: authHeaders(t) });
      await reload();
    } else {
      writeLocal(readLocal().filter(s => s.address.toLowerCase() !== leader.toLowerCase()));
    }
  }, [authenticated, getAccessToken, reload]);

  const setStatus = useCallback(async (leader: string, status: CopySub['status']) => {
    if (authenticated) {
      const t = await getAccessToken(); if (!t) return;
      const cur = (await fetchBackend(t)).find(s => s.address.toLowerCase() === leader.toLowerCase());
      if (cur) { await putBackend(t, leader, cur.config, status); await reload(); }
    } else {
      writeLocal(readLocal().map(s => s.address.toLowerCase() === leader.toLowerCase() ? { ...s, status } : s));
    }
  }, [authenticated, getAccessToken, reload]);

  const get = useCallback((leader: string): CopySub | undefined =>
    subs.find(s => s.address.toLowerCase() === leader.toLowerCase()), [subs]);

  return { subs, upsert, remove, setStatus, get };
}

📜 Git History

1587787feat(poli): chunk B3 — FADE direction + risk-band in CopyConfigModal10 days ago
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...