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