← Back
β˜†
/**
 * Polymarket CLOB order building + EIP-712 signing via viem.
 *
 * CLOB V2 (live since 28 Apr 2026). Exchange: "Polymarket CTF Exchange" v2,
 * Polygon chainId 137. The signed struct drops taker/nonce/feeRateBps and adds
 * timestamp/metadata/builder; builder attribution lives INSIDE the signature
 * (the V1 body `builder_code` field is ignored). Verified byte-for-byte against
 * py-clob-client-v2 v1.0.1 β€” see docs/CLOB_V2_MIGRATION.md (Step A hash-proof).
 */

// Exchange contract addresses (Polygon mainnet) β€” V2
const CTF_EXCHANGE = '0xE111180000d2663C0091e4f400237545B87B996B' as const;
const NEG_RISK_CTF_EXCHANGE = '0xe2222d279d744050d28e00520010520000310F59' as const;
const ZERO_BYTES32 = '0x0000000000000000000000000000000000000000000000000000000000000000' as const;

// Builder code for SZHub attribution (bytes32, inside the signed struct).
// Correct value verified against the live builder-fees API β€” NOT the naΓ―ve
// hex of "SZHub" (0x535a4875…) which attributes nothing.
const BUILDER_CODE = '0x3c829d5150b70f3ba347670d4b1eda96be3c255b3f64895a7eeef5caea7952d5' as const;

// EIP-712 types matching the V2 Exchange contract (exact field order)
const ORDER_TYPES = {
  Order: [
    { name: 'salt', type: 'uint256' },
    { name: 'maker', type: 'address' },
    { name: 'signer', type: 'address' },
    { name: 'tokenId', type: 'uint256' },
    { name: 'makerAmount', type: 'uint256' },
    { name: 'takerAmount', type: 'uint256' },
    { name: 'side', type: 'uint8' },
    { name: 'signatureType', type: 'uint8' },
    { name: 'timestamp', type: 'uint256' },
    { name: 'metadata', type: 'bytes32' },
    { name: 'builder', type: 'bytes32' },
  ],
} as const;

// Side enum matching CLOB API
export const BUY = 0;
export const SELL = 1;

// Signature types
const EOA = 0;

// Polymarket requires orders to be made by a proxy/Safe wallet ("deposit wallet
// flow") β€” a raw EOA maker is rejected. When VITE_POLY_FUNDER is set, the order
// is made by that proxy (maker = funder), signed by the connected EOA (signer),
// with the configured signatureType (1 = POLY_PROXY, 2 = POLY_GNOSIS_SAFE).
const FUNDER = (import.meta.env.VITE_POLY_FUNDER || '').trim();
const SIG_TYPE = Number(import.meta.env.VITE_POLY_SIG_TYPE || EOA);

export interface OrderParams {
  /** YES or NO token ID */
  tokenId: string;
  /** Price per share (0.01 to 0.99) */
  price: number;
  /** Number of shares */
  size: number;
  /** BUY (0) or SELL (1) */
  side: number;
  /** Whether this is a neg_risk market */
  negRisk: boolean;
}

export interface SignedOrder {
  order: {
    salt: number;
    maker: string;
    signer: string;
    tokenId: string;
    makerAmount: string;
    takerAmount: string;
    side: string;
    expiration: string;
    signatureType: number;
    timestamp: string;
    metadata: string;
    builder: string;
    signature: string;
  };
}

// Match py-clob-client-v2's generate_order_salt: int(random() * now_ms). This
// stays within JS safe-integer range so it can be sent to CLOB as a JSON number
// (a 256-bit salt would lose precision and the CLOB payload schema rejects a
// string salt with "Invalid order payload").
function generateSalt(): bigint {
  return BigInt(Math.floor(Math.random() * Date.now()));
}

/**
 * Convert price + size (shares) to maker/taker amounts (USDC has 6 decimals).
 * BUY: maker pays (price * size) USDC, taker provides (size) shares
 * SELL: maker provides (size) shares, taker pays (price * size) USDC
 *
 * CLOB rejects market orders whose amounts exceed the market's allowed decimal
 * precision ("invalid amounts … maker amount supports a max accuracy of 2
 * decimals, taker amount a max of 5 decimals"). py-clob-client rounds the USDC
 * side to `size`=2 decimals and the share side to `amount` decimals (3–6,
 * tick-dependent). Since `size` is always 2 and `amount` is always β‰₯3, rounding
 * BOTH sides down to 2 decimals is within every tier's limit (no per-market tick
 * lookup needed) and stays marketable for FOK. The rounded values are what gets
 * signed, so signature and payload stay consistent.
 */
function roundDown2(x: number): bigint {
  // value rounded down to 2 decimals, expressed in USDC/CTF 6-decimal units
  return BigInt(Math.floor(x * 100)) * 10_000n;
}

function roundNearest2(x: number): bigint {
  // The USDC side must NOT be floored: size*price drifts to e.g. 0.9999999 by
  // floating point, and flooring gives $0.99 β†’ below the $1 min order size.
  // Round to the nearest cent so the spent amount equals the user's intent.
  return BigInt(Math.round(x * 100)) * 10_000n;
}

function calcAmounts(price: number, size: number, side: number): { makerAmount: bigint; takerAmount: bigint } {
  const usdc = size * price;
  if (side === BUY) {
    // Maker pays USDC (nearest cent), taker gives shares (floored to 2 dec)
    return { makerAmount: roundNearest2(usdc), takerAmount: roundDown2(size) };
  } else {
    // Maker gives shares (floored), taker pays USDC (nearest cent)
    return { makerAmount: roundDown2(size), takerAmount: roundNearest2(usdc) };
  }
}

/**
 * Build order struct and EIP-712 typed data for signing.
 */
export function buildOrderForSigning(
  eoa: `0x${string}`,
  params: OrderParams,
) {
  const exchangeAddress = params.negRisk ? NEG_RISK_CTF_EXCHANGE : CTF_EXCHANGE;
  const { makerAmount, takerAmount } = calcAmounts(params.price, params.size, params.side);
  const salt = generateSalt();

  // maker = proxy/Safe (funder) when configured; signer is always the connected
  // EOA that actually produces the signature.
  const maker = (FUNDER || eoa) as `0x${string}`;

  const order = {
    salt,
    maker,
    signer: eoa,
    tokenId: BigInt(params.tokenId),
    makerAmount,
    takerAmount,
    side: params.side,
    signatureType: SIG_TYPE,
    timestamp: BigInt(Date.now()), // ms, part of the signed struct
    metadata: ZERO_BYTES32,
    builder: BUILDER_CODE,
  };

  const domain = {
    name: 'Polymarket CTF Exchange',
    version: '2',
    chainId: 137,
    verifyingContract: exchangeAddress,
  } as const;

  return { order, domain, types: ORDER_TYPES };
}

/**
 * Convert signed order to the V2 CLOB body shape: `side` as a string, and the
 * signature nested INSIDE the order object (not a sibling).
 */
export function formatSignedOrder(
  order: ReturnType<typeof buildOrderForSigning>['order'],
  signature: string,
): SignedOrder {
  return {
    order: {
      // CLOB expects salt as a JSON number (matches py-clob-client-v2). Safe
      // because generateSalt() keeps it within JS safe-integer range.
      salt: Number(order.salt),
      maker: order.maker,
      signer: order.signer,
      tokenId: order.tokenId.toString(),
      makerAmount: order.makerAmount.toString(),
      takerAmount: order.takerAmount.toString(),
      side: order.side === BUY ? 'BUY' : 'SELL',
      expiration: '0',
      signatureType: order.signatureType,
      timestamp: order.timestamp.toString(),
      metadata: order.metadata,
      builder: order.builder,
      signature,
    },
  };
}

πŸ“œ Git History

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