Skip to main content
Back to App

EIP-712 order signing

Every order on Conviction is signed as EIP-712 typed data. Regular users don't need to worry about this — the UI handles it automatically. This page is a reference for bots and market makers that want to post orders programmatically without the UI.

#Domain (EIP-712 Domain Separator)

{
  name: 'Conviction CTF Exchange', // must match byte-for-byte
  version: '1',
  chainId: 56,                           // BSC mainnet
  verifyingContract: CTF_EXCHANGE_ADDRESS, // address varies by environment
}
A single wrong character in the domain name breaks it

Even with a typo, the ECDSA itself passes, so it looks fine at signing time on the client. But because it differs from the contract-side domain, it reverts at fill time.

verifyingContract is the CTFExchange address on BSC mainnet. Fetch it from a server response or an environment variable — don't hardcode it in the client.

#Order structure

FieldTypeMeaning
saltuint256Random identifier (newly generated on every re-sign)
makeraddressThe user's address. Via EIP-7702 delegation the EOA is also the AA, so maker == signer.
signeraddressThe user's EOA — the same address as maker.
takeraddress0x0000... (public order)
tokenIduint256The outcome's position ID (= the outcome's position_id)
makerAmountuint256The wei amount of the asset the maker offers
takerAmountuint256The wei amount of the asset the maker wants to receive
expirationuint256Order expiration in unix seconds. 0 = no expiration
nonceuint256Fresh every time via CTFExchange.nonces(maker)
feeRateBpsuint256Fee cap (basis points)
sideuint8BUY = 0, SELL = 1
signatureTypeuint8EOA = 0 (user order)

#EIP-712 type definition

const types = {
  Order: [
    { name: 'salt',          type: 'uint256' },
    { name: 'maker',         type: 'address' },
    { name: 'signer',        type: 'address' },
    { name: 'taker',         type: 'address' },
    { name: 'tokenId',       type: 'uint256' },
    { name: 'makerAmount',   type: 'uint256' },
    { name: 'takerAmount',   type: 'uint256' },
    { name: 'expiration',    type: 'uint256' },
    { name: 'nonce',         type: 'uint256' },
    { name: 'feeRateBps',    type: 'uint256' },
    { name: 'side',          type: 'uint8'   },
    { name: 'signatureType', type: 'uint8'   },
  ],
};

#Single-order signing example (viem)

import { parseUnits } from 'viem';
 
const SHARES = parseUnits('100', 18);
 
const order = {
  salt: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
  maker: eoaAddress,                 // EIP-7702: the EOA is also the AA
  signer: eoaAddress,                // the same address
  taker: '0x0000000000000000000000000000000000000000',
  tokenId: BigInt(outcome.position_id),
  // BUY 100 shares at 0.5 USDT each
  makerAmount: parseUnits('50', 18),       // 100 × 0.5 = 50 USDT
  takerAmount: SHARES,                     // 100 shares
  expiration: BigInt(Math.floor(Date.now() / 1000) + 3600),
  nonce: await ctfExchange.read.nonces([eoaAddress]),
  feeRateBps: BigInt(market.platformFee),
  side: 0n,                                 // BUY
  signatureType: 0,                         // EOA
};
 
const signature = await walletClient.signTypedData({
  account: eoaAddress,
  domain,
  types: { Order: types.Order },
  primaryType: 'Order',
  message: order,
});
 
// POST /orders
await fetch('/api/orders', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    marketId: market.id,
    outcomeId: outcome.id,
    side: 'buy',
    type: 'limit',
    price: 0.5,
    shares: 100,
    userId: user.id,
    salt: '0x' + order.salt.toString(16),
    signature,
    signerAddress: order.signer,
    makerAddress: order.maker,
    expiration: order.expiration.toString(),
    signatureType: 0,
    tokenId: order.tokenId.toString(),
    makerAmount: order.makerAmount.toString(),
    takerAmount: order.takerAmount.toString(),
    feeRateBps: market.platformFee,
  }),
});

#makerAmount / takerAmount calculation rules

All amounts are 18-decimal wei integers.

SideWhat maker offersWhat maker receivesmakerAmounttakerAmount
BUYUSDTOutcome tokensshares × price (USDT wei)shares (token wei)
SELLOutcome tokensUSDTshares (token wei)shares × price (USDT wei)
  • price is a 0..1 float, but in wei-space it's used in multiplication as price × 1e18.
  • Do all multiplication with bigint arithmetic — no JS Number (1 USDT = 10^18, which exceeds the safe-integer range).

#Common mistakes

These four come up most often
  1. Putting different addresses in maker and signer. In the EIP-7702 model, both must be the same — the user's EOA address.
  2. Caching the nonce. Fetch fresh every time via CTFExchange.nonces(maker). If incrementNonce() is called, every unfilled order on the previous nonce reverts.
  3. Sending price as a percent or bps. The price in the payload is a 0..1 float.
  4. Sending makerAmount / takerAmount in human-readable units. Always a decimal string of 18-dec wei.

#Signatures are verified at fill time

  • The backend does not verify signatures — it stores the signed payload as-is and hands it to the chain at fill time.
  • A bad signature reverts at fill time because on-chain ECDSA recovery returns a different address → that order is marked failed.
  • So the signing step must be exact — avoid the four mistakes above.

#Next

  • REST → Markets — query the market data needed for orders, such as feeRateBps
  • User wallet — the single-address (EOA == AA) model
Was this page helpful?