/**
* 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