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