← Back
import { useState, useEffect, useCallback, useRef } from 'react';
import { useT } from '../i18n/LanguageContext';
import { useAccount, useSignTypedData, useChainId, useSwitchChain } from 'wagmi';
import { withTimeout } from '../utils/withTimeout';
import { PolymarketAuthContext, type AuthValue, type ClobCredentials } from './polymarketAuthContext';

const POLYGON_CHAIN_ID = 137;
const SIGN_TIMEOUT_MS = 60_000;

const STORAGE_KEY = 'polymarket_clob_creds';
const CLOB_AUTH_DOMAIN = {
  name: 'ClobAuthDomain',
  version: '1',
  chainId: 137,
} as const;

const CLOB_AUTH_TYPES = {
  ClobAuth: [
    { name: 'address', type: 'address' },
    { name: 'timestamp', type: 'string' },
    { name: 'nonce', type: 'uint256' },
    { name: 'message', type: 'string' },
  ],
} as const;

function getStoredCreds(address: string): ClobCredentials | null {
  try {
    const raw = sessionStorage.getItem(`${STORAGE_KEY}_${address.toLowerCase()}`);
    return raw ? JSON.parse(raw) : null;
  } catch {
    return null;
  }
}

function storeCreds(address: string, creds: ClobCredentials) {
  sessionStorage.setItem(
    `${STORAGE_KEY}_${address.toLowerCase()}`,
    JSON.stringify(creds)
  );
}

function clearCreds(address: string) {
  sessionStorage.removeItem(`${STORAGE_KEY}_${address.toLowerCase()}`);
}

// Single source of truth for CLOB auth. Without this, every component that
// called usePolymarketAuth() got its own useState copy — signing in the TopBar
// left the OrderForm's separate copy stuck on "Sign to Trade".
export function PolymarketAuthProvider({ children }: { children: React.ReactNode }) {
  const { t } = useT();
  const { address, isConnected } = useAccount();
  const { signTypedDataAsync } = useSignTypedData();
  const chainId = useChainId();
  const { switchChainAsync } = useSwitchChain();

  const [credentials, setCredentials] = useState<ClobCredentials | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  // Guards against firing a second eth_signTypedData while one is in flight —
  // the wallet rejects concurrent requests with "already pending".
  const inFlight = useRef(false);

  // Restore from sessionStorage on connect, clear on disconnect
  useEffect(() => {
    const timer = setTimeout(() => {
      if (isConnected && address) {
        const stored = getStoredCreds(address);
        if (stored) setCredentials(stored);
      } else {
        setCredentials(null);
        setError(null);
      }
    }, 0);
    return () => { clearTimeout(timer); };
  }, [isConnected, address]);

  const deriveApiKey = useCallback(async () => {
    if (!address || !isConnected) {
      setError('Wallet not connected');
      return;
    }
    if (inFlight.current) return; // a signature request is already pending

    inFlight.current = true;
    setLoading(true);
    setError(null);

    try {
      // Polymarket lives on Polygon; the ClobAuth domain is chainId 137. If the
      // wallet is on another network (e.g. Ethereum), signing fails with
      // "Active chainId is 0x1 but received 0x89" — switch first.
      if (chainId !== POLYGON_CHAIN_ID) {
        await withTimeout(
          switchChainAsync({ chainId: POLYGON_CHAIN_ID }),
          SIGN_TIMEOUT_MS,
          'Network switch timed out. Open your wallet app, confirm, and try again.',
        );
      }

      const timestamp = Math.floor(Date.now() / 1000).toString();
      const nonce = 0n;

      // Sign EIP-712 message with wallet. On mobile WalletConnect the wallet app
      // may not surface — guard with a timeout so we never spin forever.
      const signature = await withTimeout(
        signTypedDataAsync({
          domain: CLOB_AUTH_DOMAIN,
          types: CLOB_AUTH_TYPES,
          primaryType: 'ClobAuth',
          message: {
            address,
            timestamp,
            nonce,
            message: 'This message attests that I control the given wallet',
          },
        }),
        SIGN_TIMEOUT_MS,
        'Signature timed out. Open your wallet app, confirm, and try again.',
      );

      // Send to our backend which proxies to CLOB API
      const res = await fetch('/api/auth/derive-key', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          address,
          signature,
          timestamp,
          nonce: 0,
        }),
      });

      const data = await res.json();
      if (!data.success) {
        throw new Error(data.error || 'Failed to derive API key');
      }

      const creds: ClobCredentials = data.data;
      storeCreds(address, creds);
      setCredentials(creds);
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'Signing failed';
      // On mobile WalletConnect a *lost* signature surfaces as "User rejected"
      // even when the wallet actually signed — the relay WebSocket drops while
      // the browser is backgrounded, so wagmi never receives the response.
      // Don't reset silently (that looks like nothing happened); tell the user
      // to tap again, which usually succeeds on the second attempt.
      if (msg.includes('already pending')) {
        // A prior signature request is stuck in the WalletConnect session
        // (common on mobile when the response was lost). The new one is blocked.
        setError(t('err.signPendingLong'));
      } else if (msg.includes('User rejected') || msg.includes('user rejected')) {
        setError(t('err.signNotReceived'));
      } else {
        setError(msg);
      }
    } finally {
      inFlight.current = false;
      setLoading(false);
    }
  }, [address, isConnected, signTypedDataAsync, chainId, switchChainAsync, t]);

  const logout = useCallback(() => {
    if (address) clearCreds(address);
    setCredentials(null);
    setError(null);
  }, [address]);

  const value: AuthValue = {
    isAuthenticated: !!credentials,
    isConnected,
    credentials,
    address,
    loading,
    error,
    deriveApiKey,
    logout,
  };

  return (
    <PolymarketAuthContext.Provider value={value}>
      {children}
    </PolymarketAuthContext.Provider>
  );
}

📜 Git History

6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...