← Back
β˜†
import { useState, useEffect, useCallback } from 'react';
import { usePrivy, useSigners } from '@privy-io/react-auth';
import { usePrivySigner } from '../hooks/usePrivySigner';
import useRelayClient from '../hooks/useRelayClient';
import useDepositWallet from '../hooks/useDepositWallet';
import { checkDwApprovals, readPusdBalance } from '../utils/dwApprovals';
import DepositPanel from './DepositPanel';
import { useT } from '../i18n/LanguageContext';

/**
 * Privy email login + embedded wallet + Polymarket DEPOSIT-WALLET setup (copy-trading).
 * Login β†’ embedded EOA β†’ deploy deterministic deposit-wallet + trading approvals (gasless
 * via RelayClient, user-present) β†’ delegate server signer for auto-copy. The deposit-wallet
 * is what the copy demon trades from (NOT a Gnosis Safe).
 */
function fmtAddr(a: string) { return `${a.slice(0, 6)}…${a.slice(-4)}`; }

export default function PrivyAccount() {
  const { t } = useT();
  const { ready, authenticated, user, login, logout, getAccessToken } = usePrivy();
  const { eoaAddress } = usePrivySigner();
  const { initializeRelayClient } = useRelayClient();
  const { deriveDepositWallet, isDepositWalletDeployed, deployDepositWallet, approveDepositWallet, withdrawDepositWallet, redeemResolved } = useDepositWallet();
  const { addSigners } = useSigners();
  const SIGNER_ID = import.meta.env.VITE_PRIVY_SIGNER_ID as string | undefined;
  const [dwAddress, setDwAddress] = useState<string | undefined>();
  const [safeStatus, setSafeStatus] = useState<'idle' | 'working' | 'done' | 'error'>('idle');
  const [safeMsg, setSafeMsg] = useState('');

  // Delegation status: is the embedded wallet provisioned for server-side signing?
  const isDelegated = !!user?.linkedAccounts?.some(
    (a) => a.type === 'wallet'
      && (a as { walletClientType?: string }).walletClientType === 'privy'
      && (a as { delegated?: boolean }).delegated,
  );

  // Tell the backend this user's deposit-wallet so the copy demon knows where to trade.
  const userEmail = user?.email?.address;
  const registerWallet = useCallback(async (dw: string) => {
    try {
      const token = await getAccessToken();
      if (!token) return;
      await fetch('/api/copy/wallet', {
        method: 'POST',
        headers: { 'content-type': 'application/json', Authorization: `Bearer ${token}` },
        body: JSON.stringify({ deposit_wallet: dw, owner_eoa: eoaAddress || undefined, email: userEmail || undefined }),
      });
    } catch { /* non-fatal: demon falls back / retries on next load */ }
  }, [getAccessToken, eoaAddress, userEmail]);

  const walletReady = safeStatus === 'done' || isDelegated;
  const [panel, setPanel] = useState<'none' | 'deposit' | 'withdraw'>('none');
  const [pusdBal, setPusdBal] = useState<bigint>(0n);
  const [wAmt, setWAmt] = useState('');
  const [wTo, setWTo] = useState('');
  const [wBusy, setWBusy] = useState(false);
  const [wMsg, setWMsg] = useState('');
  const [rBusy, setRBusy] = useState(false);
  const [rMsg, setRMsg] = useState('');

  // Manual redeem of resolved positions (visible result/errors) β€” reliable trigger vs the
  // background effect (which can be masked by load timing / silent catch).
  const doRedeem = async () => {
    if (!dwAddress) return;
    setRBusy(true); setRMsg('Checking resolved positions…');
    try {
      const relay = await initializeRelayClient();
      const n = await redeemResolved(relay, dwAddress);
      setRMsg(n > 0 ? `βœ… Redeemed ${n} resolved market(s) β†’ pUSD` : 'Nothing to redeem');
    } catch (e) {
      setRMsg('❌ ' + (e instanceof Error ? e.message : String(e)));
    } finally {
      setRBusy(false);
    }
  };

  // Load the DW pUSD balance (and prefill withdraw destination = the user's own EOA).
  useEffect(() => {
    if (!dwAddress || !walletReady) return;
    let cancelled = false;
    readPusdBalance(dwAddress).then((b) => {
      if (cancelled) return;
      setPusdBal(b);
      // Prefill withdraw destination with the user's own EOA (only if untouched).
      setWTo((prev) => prev || eoaAddress || '');
    });
    return () => { cancelled = true; };
  }, [dwAddress, walletReady, eoaAddress]);

  const doWithdraw = async () => {
    setWMsg('');
    const amt = Number(wAmt);
    if (!Number.isFinite(amt) || amt <= 0) { setWMsg(t('wd.badAmt')); return; }
    const to = wTo.trim();
    if (!to.startsWith('0x') || to.length !== 42) { setWMsg(t('wd.badTo')); return; }
    const amountRaw = BigInt(Math.round(amt * 1e6));
    if (amountRaw > pusdBal) { setWMsg(t('wd.badAmt')); return; }
    if (!dwAddress) return;
    setWBusy(true);
    try {
      const relay = await initializeRelayClient();
      const ok = await withdrawDepositWallet(relay, dwAddress, to, amountRaw);
      if (!ok) throw new Error(t('wd.err'));
      setWMsg(t('wd.done')); setWAmt('');
      setPusdBal(await readPusdBalance(dwAddress));
    } catch (e) {
      setWMsg(e instanceof Error ? e.message : t('wd.err'));
    } finally {
      setWBusy(false);
    }
  };


  // On load, derive the deposit-wallet and detect whether it's already deployed + approved,
  // so the card shows "Π³ΠΎΡ‚ΠΎΠ²" instead of the setup button forever (state isn't persisted).
  // All read-only (no signing) β€” safe to run on mount.
  useEffect(() => {
    if (!eoaAddress || !authenticated || safeStatus !== 'idle') return;
    let cancelled = false;
    (async () => {
      try {
        const relay = await initializeRelayClient();
        const dw = await deriveDepositWallet(relay);
        if (cancelled) return;
        setDwAddress(dw);
        const deployed = await isDepositWalletDeployed(dw);
        if (!deployed) return;
        const { allApproved } = await checkDwApprovals(dw);
        if (!cancelled && allApproved) { setSafeStatus('done'); registerWallet(dw); }
      } catch { /* leave idle β†’ keep showing the setup button */ }
    })();
    return () => { cancelled = true; };
  }, [eoaAddress, authenticated, initializeRelayClient, deriveDepositWallet, isDepositWalletDeployed, safeStatus, registerWallet]);

  // Variant B: the server delegate is added WITHOUT a policy at activation (see activateWallet β†’
  // addSigners with no policyIds), so the server cron can auto-redeem resolved positions. No
  // auto-detach effect (it churned removeSigners on every load β†’ Privy rate-limit β†’ lost delegate).
  // ⚠️ TODO (revisit at scale): restore a "server can't withdraw" guarantee via a re-architected
  // redeem path the policy CAN allow (direct eth_sendTransaction to CTF, gated by transaction.to).

  // Auto-redeem resolved positions, user-present (owner-signed via the relayer batch). The
  // orders-only policy restricts only the SERVER delegate, so the server cron can no longer redeem
  // (Batch domain DENY) β€” the user does it on load instead. No-op (no signature) when nothing is
  // redeemable, so it's silent until a market the user holds resolves.
  // Guard: attempt at most ONCE per browser session per deposit-wallet β€” PrivyAccount re-mounts on
  // every navigation, which re-fired this and made the "Sign message" prompt pop up repeatedly.
  // The flag is set BEFORE the call so re-mounts during the async can't double-prompt; a page
  // reload clears sessionStorage β†’ genuine retry still possible.
  useEffect(() => {
    if (!walletReady || !dwAddress) return;
    const guardKey = `sz_redeem_tried_${dwAddress.toLowerCase()}`;
    try { if (sessionStorage.getItem(guardKey)) return; } catch { /* sessionStorage blocked β†’ run once */ }
    let cancelled = false;
    (async () => {
      try {
        try { sessionStorage.setItem(guardKey, '1'); } catch { /* ignore */ }
        const relay = await initializeRelayClient();
        if (cancelled) return;
        await redeemResolved(relay, dwAddress);
      } catch { /* non-fatal: retry on next reload */ }
    })();
    return () => { cancelled = true; };
  }, [walletReady, dwAddress, initializeRelayClient, redeemResolved]);

  // One-tap activation: deploy the deposit wallet + trading approvals, then delegate our
  // server signer so the copy daemon can place orders β€” all in a single user-present flow.
  const activateWallet = async () => {
    setSafeStatus('working'); setSafeMsg('');
    try {
      const relay = await initializeRelayClient();
      const dw = await deriveDepositWallet(relay);
      setDwAddress(dw);
      const deployed = await isDepositWalletDeployed(dw);
      if (!deployed) await deployDepositWallet(relay);
      const { allApproved } = await checkDwApprovals(dw);
      if (!allApproved) {
        const ok = await approveDepositWallet(relay, dw);
        if (!ok) throw new Error(t('acct.errDeploy'));
      }
      await registerWallet(dw);
      // Delegate the server signer for auto-copy (skip if already delegated).
      if (!isDelegated) {
        if (!SIGNER_ID || !eoaAddress) throw new Error(t('acct.errNoSigner'));
        await addSigners({ address: eoaAddress, signers: [{ signerId: SIGNER_ID }] });
      }
      setSafeStatus('done');
    } catch (e) {
      setSafeStatus('error');
      setSafeMsg(e instanceof Error ? e.message : t('acct.errDeploy'));
    }
  };

  if (!ready) {
    return <div className="acct-card"><div className="acct-title">{t('acct.title')}</div><div className="acct-sub">{t('acct.loading')}</div></div>;
  }

  return (
    <div className="acct-card">
      <div className="acct-title">{t('acct.title')}</div>
      {authenticated ? (
        <>
          <div className="acct-row">
            <span className="acct-dot acct-dot-on" />
            <span className="acct-addr">{user?.email?.address || t('acct.loggedIn')}</span>
          </div>
          {eoaAddress && <div className="acct-sub">{t('acct.wallet')}: {fmtAddr(eoaAddress)}</div>}
          {dwAddress && (
            <div className="acct-sub">{t('acct.tradingSafe')}: {fmtAddr(dwAddress)}</div>
          )}

          {(safeStatus === 'done' && isDelegated) ? (
            <div className="acct-sub" style={{ color: '#16a34a' }}>{t('acct.walletActive')}</div>
          ) : (
            <button className="acct-btn" onClick={activateWallet} disabled={safeStatus === 'working' || !eoaAddress}>
              {safeStatus === 'working' ? t('acct.activating') : t('acct.activateWallet')}
            </button>
          )}
          {safeStatus === 'error' && <div className="acct-err">{safeMsg}</div>}

          {dwAddress && walletReady && (
            <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
              <button className="acct-btn" style={{ flex: 1 }}
                onClick={() => setPanel((p) => (p === 'deposit' ? 'none' : 'deposit'))}>
                {panel === 'deposit' ? t('acct.close') : t('acct.deposit')}
              </button>
              <button className="acct-btn acct-btn-ghost" style={{ flex: 1 }}
                onClick={() => setPanel((p) => (p === 'withdraw' ? 'none' : 'withdraw'))}>
                {panel === 'withdraw' ? t('acct.close') : t('acct.withdraw')}
              </button>
            </div>
          )}

          {dwAddress && walletReady && (
            <>
              <button className="acct-btn acct-btn-ghost" onClick={doRedeem} disabled={rBusy} style={{ marginTop: 8, fontSize: 13 }}>
                {rBusy ? t('acct.redeeming') : t('acct.redeem')}
              </button>
              {rMsg && <div className="acct-sub" style={{ marginTop: 4, fontSize: 12 }}>{rMsg}</div>}
            </>
          )}

          {dwAddress && walletReady && panel === 'deposit' && (
            <DepositPanel dwAddress={dwAddress} />
          )}

          {dwAddress && walletReady && panel === 'withdraw' && (
            <div className="acct-withdraw" style={{ marginTop: 12 }}>
              <div className="acct-sub" style={{ fontWeight: 600 }}>{t('wd.title')}</div>
              <div className="acct-sub" style={{ opacity: 0.8 }}>{t('wd.avail')}: {(Number(pusdBal) / 1e6).toFixed(2)} pUSD</div>
              <div className="acct-sub" style={{ display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0' }}>
                <input className="acct-input" type="number" min={0} step="0.01" placeholder={t('wd.amount')}
                  value={wAmt} onChange={(e) => setWAmt(e.target.value)} style={{ flex: 1, minWidth: 0 }} />
                <button className="acct-btn" style={{ width: 'auto', padding: '4px 10px' }}
                  onClick={() => setWAmt((Number(pusdBal) / 1e6).toString())}>{t('wd.max')}</button>
              </div>
              <input className="acct-input" placeholder={t('wd.to')} value={wTo}
                onChange={(e) => setWTo(e.target.value)} style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
              <button className="acct-btn" onClick={doWithdraw} disabled={wBusy} style={{ marginTop: 6 }}>
                {wBusy ? t('wd.busy') : t('wd.btn')}
              </button>
              {wMsg && <div className="acct-sub" style={{ marginTop: 4 }}>{wMsg}</div>}
            </div>
          )}

          <button className="acct-btn acct-btn-ghost" onClick={logout} style={{ marginTop: 8 }}>{t('acct.logout')}</button>
        </>
      ) : (
        <>
          <div className="acct-sub">{t('acct.signinDesc')}</div>
          <button className="acct-btn" onClick={login}>{t('acct.signinEmail')}</button>
        </>
      )}
    </div>
  );
}

πŸ“œ Git History

6555979fix(poli): TODO #1 sign-popup session-guard + #3 editorial header wordmark10 days ago
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...