import { useState, useMemo } from 'react';
import { useT } from '../../i18n/LanguageContext';
import { useSignTypedData, useBalance, useChainId, useSwitchChain } from 'wagmi';
import { usePolymarketAuth } from '../../hooks/usePolymarketAuth';
import { buildOrderForSigning, formatSignedOrder, BUY } from '../../utils/order';
import { withTimeout } from '../../utils/withTimeout';
const SIGN_TIMEOUT_MS = 60_000;
const USDC_POLYGON = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as const;
type Side = 'yes' | 'no';
type OrderType = 'market' | 'limit';
interface Props {
yesPrice: number;
noPrice: number;
yesTokenId: string | null;
noTokenId: string | null;
negRisk: boolean;
initialSide?: Side;
}
const PRESETS = [0.25, 0.5, 0.75, 1] as const;
export default function OrderForm({ yesPrice, noPrice, yesTokenId, noTokenId, negRisk, initialSide = 'yes' }: Props) {
const { t } = useT();
const { isConnected, isAuthenticated, credentials, address, deriveApiKey, loading: authLoading, error: authError } = usePolymarketAuth();
const { signTypedDataAsync } = useSignTypedData();
const chainId = useChainId();
const { switchChainAsync } = useSwitchChain();
// Real USDC balance on Polygon for preset buttons
const { data: balanceData, isLoading: balanceLoading } = useBalance({
address: address as `0x${string}` | undefined,
token: USDC_POLYGON,
query: { enabled: !!address },
});
const [side, setSide] = useState<Side>(initialSide);
const [orderType, setOrderType] = useState<OrderType>('market');
const [amount, setAmount] = useState('');
const [limitPrice, setLimitPrice] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const activeTokenId = side === 'yes' ? yesTokenId : noTokenId;
// Current best price based on side
const bestPrice = side === 'yes' ? yesPrice : noPrice;
// Effective price for calculations
const effectivePrice = useMemo(() => {
if (orderType === 'market') return bestPrice;
const lp = parseFloat(limitPrice);
return lp > 0 && lp <= 99 ? lp / 100 : 0;
}, [orderType, bestPrice, limitPrice]);
// Calculate shares and payout
const amountNum = parseFloat(amount) || 0;
const shares = effectivePrice > 0 ? amountNum / effectivePrice : 0;
const payout = shares; // Each share pays $1 if resolved YES/NO
const profit = payout - amountNum;
const canSubmit = amountNum >= 1 && effectivePrice > 0 && effectivePrice < 1
&& isAuthenticated && !submitting && !!activeTokenId && !!address;
const showToast = (msg: string, type: 'success' | 'error') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 4000);
};
const handleSubmit = async () => {
if (!canSubmit || !activeTokenId || !address || !credentials) return;
setSubmitting(true);
try {
// Order domain is Polygon (137) — switch if the wallet is elsewhere.
if (chainId !== 137) {
await withTimeout(
switchChainAsync({ chainId: 137 }),
SIGN_TIMEOUT_MS,
'Network switch timed out. Open your wallet app, confirm, and try again.',
);
}
// Market orders are FOK (fill-or-kill): a limit set exactly at the best
// price almost always kills, because sweeping the book ticks the price up.
// Give market buys slippage room (cap at 99¢) so they can actually fill;
// limit orders use the user's exact price. Both sides here are buys.
const MARKET_SLIPPAGE = 0.05;
const orderPrice = orderType === 'market'
? Math.min(effectivePrice + MARKET_SLIPPAGE, 0.99)
: effectivePrice;
const orderShares = orderPrice > 0 ? amountNum / orderPrice : 0;
// Build the EIP-712 order
const { order, domain, types } = buildOrderForSigning(
address as `0x${string}`,
{
tokenId: activeTokenId,
price: orderPrice,
size: orderShares,
side: BUY,
negRisk,
},
);
// Sign with wallet (MetaMask popup). On mobile WalletConnect the wallet
// app may not surface — guard with a timeout so we never spin forever.
const signature = await withTimeout(
signTypedDataAsync({
domain,
types,
primaryType: 'Order',
message: order,
}),
SIGN_TIMEOUT_MS,
'Signature timed out. Open your wallet app, confirm, and try again.',
);
// Format and send to backend (credentials stored server-side, not sent)
const signedOrder = formatSignedOrder(order, signature);
const res = await fetch('/api/trade/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
signedOrder,
orderType: orderType === 'market' ? 'FOK' : 'GTC',
owner: address,
}),
});
const data = await res.json();
if (data.success) {
setAmount('');
setLimitPrice('');
showToast('Order submitted ✅', 'success');
} else if (data.error === 'credentials_expired') {
// Server lost credentials (restart) — re-derive automatically
showToast('Session expired, re-signing…', 'error');
await deriveApiKey();
} else {
showToast(data.error || 'Order failed', 'error');
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Order failed';
if (msg.includes('already pending')) {
showToast(t('err.signPending'), 'error');
} else if (!msg.includes('User rejected')) {
showToast(msg, 'error');
}
} finally {
setSubmitting(false);
}
};
const usdcBalance = balanceData?.value
? Number(balanceData.value) / 1_000_000 // USDC has 6 decimals
: 0;
const applyPreset = (pct: number) => {
if (usdcBalance <= 0) return;
setAmount((usdcBalance * pct).toFixed(2));
};
// Not connected state
if (!isConnected) {
return (
<div className="order-form">
<div className="order-form-disabled">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="24" height="24">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<p>Connect wallet to trade</p>
</div>
</div>
);
}
// Connected but not signed
if (!isAuthenticated) {
return (
<div className="order-form">
<div className="order-form-disabled">
<p>Sign to enable trading</p>
<button className="btn-sign-form" onClick={deriveApiKey} disabled={authLoading}>
{authLoading ? 'Signing…' : '🔑 Sign to Trade'}
</button>
{authError && <p className="sign-error-hint">{authError}</p>}
</div>
</div>
);
}
return (
<div className="order-form">
<h4 className="order-form-title">Trade</h4>
{/* Side toggle */}
<div className="side-toggle">
<button
className={`side-btn side-yes ${side === 'yes' ? 'side-active' : ''}`}
onClick={() => setSide('yes')}
>
Buy YES
</button>
<button
className={`side-btn side-no ${side === 'no' ? 'side-active' : ''}`}
onClick={() => setSide('no')}
>
Buy NO
</button>
</div>
{/* Order type tabs */}
<div className="order-type-tabs">
<button
className={`order-type-tab ${orderType === 'market' ? 'order-type-active' : ''}`}
onClick={() => setOrderType('market')}
>
Market
</button>
<button
className={`order-type-tab ${orderType === 'limit' ? 'order-type-active' : ''}`}
onClick={() => setOrderType('limit')}
>
Limit
</button>
</div>
{/* Amount input */}
<div className="order-field">
<label className="order-label">Amount (USDC)</label>
<div className="order-input-wrap">
<span className="order-input-prefix">$</span>
<input
type="number"
className="order-input"
placeholder="0.00"
min="1"
step="0.01"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
</div>
<div className="order-presets">
{PRESETS.map(pct => (
<button
key={pct}
className="preset-btn"
onClick={() => applyPreset(pct)}
disabled={balanceLoading || usdcBalance <= 0}
>
{pct === 1 ? 'Max' : `${pct * 100}%`}
</button>
))}
{usdcBalance > 0 && (
<span className="preset-balance">${usdcBalance.toFixed(2)}</span>
)}
</div>
</div>
{/* Limit price (only for limit orders) */}
{orderType === 'limit' && (
<div className="order-field">
<label className="order-label">Price</label>
<div className="order-input-wrap">
<input
type="number"
className="order-input"
placeholder={`${Math.round(bestPrice * 100)}`}
min="1"
max="99"
step="1"
value={limitPrice}
onChange={e => setLimitPrice(e.target.value)}
/>
<span className="order-input-suffix">¢</span>
</div>
</div>
)}
{/* Summary */}
{amountNum > 0 && effectivePrice > 0 && (
<div className="order-summary">
<div className="summary-row">
<span>Price</span>
<span>{Math.round(effectivePrice * 100)}¢ per share</span>
</div>
<div className="summary-row">
<span>Shares</span>
<span>{shares.toFixed(2)}</span>
</div>
<div className="summary-row">
<span>Potential payout</span>
<span className="summary-payout">${payout.toFixed(2)}</span>
</div>
<div className="summary-row">
<span>Potential profit</span>
<span className={profit > 0 ? 'summary-profit' : 'summary-loss'}>
{profit > 0 ? '+' : ''}${profit.toFixed(2)} ({effectivePrice > 0 ? ((profit / amountNum) * 100).toFixed(0) : 0}%)
</span>
</div>
</div>
)}
{/* Submit */}
<button
className={`order-submit ${side === 'yes' ? 'order-submit-yes' : 'order-submit-no'}`}
onClick={handleSubmit}
disabled={!canSubmit}
>
{submitting
? 'Submitting…'
: `Buy ${side.toUpperCase()} — $${amountNum.toFixed(2)}`
}
</button>
{submitting && (
<p className="order-signing-hint">📱 Open your wallet app to confirm the signature</p>
)}
<p className="order-disclaimer">
{orderType === 'market' ? 'Market order (FOK)' : 'Limit order (GTC)'} · 0.5% builder fee included in your signature · non-custodial, you sign every order
</p>
{/* Toast notification */}
{toast && (
<div className={`order-toast order-toast-${toast.type}`}>
{toast.msg}
</div>
)}
</div>
);
}
📜 Git History
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...