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...