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