Skip to main content
Every order, withdrawal, split, merge, and convert-positions request requires an EIP-712 typed-data signature. Different operations sign under different domains:
OperationDomain nameverifyingContractStruct
Place order (binary market)PredictStreetCTFExchange addressOrder (this page)
Place order (neg-risk market)PredictStreetPredictStreetNegRiskCtfExchange addressOrder (this page)
Withdraw USDCPredictStreet (same as order)CTFExchange addressWithdrawERC20 — see Withdrawals EIP-712
Split / merge / convert positionsPredictStreetVaultper-user vault clone address (VaultFactory.vaultOf(signer)), NOT the factorySplitPosition / MergePositions — see Contracts → Vaults
Using the wrong domain (name, version, or verifyingContract) produces a signature that recovers a different address than your signer. The backend returns 400 bad_signature; on-chain it reverts at _verifyOrderSignature / _verifyVault.

Order-signing domain

const domain = {
  name: 'PredictStreet',
  version: '1',
  chainId: 99999,
  verifyingContract: '0xc3c197e42AfE809a7f34D3a7eE6aDE0cF7613D2b', // CTFExchange (binary)
};
Binary vs neg-risk — pick the right verifyingContract.Both exchanges share name: "PredictStreet" + version: "1" but live at different on-chain addresses, so the EIP-712 domain separator differs.
  • Binary markets → CTFExchange (0xc3c197e42AfE809a7f34D3a7eE6aDE0cF7613D2b).
  • Neg-risk markets → PredictStreetNegRiskCtfExchange (0xB1A9274D2a9bd8a8CDd4D836e4f5273d3870211a).
Read negRiskEligible on GET /api/markets/{symbol}true means neg-risk, false (or null) means binary. The backend resolves the same flag server-side and verifies your signature against the matching domain; a mismatch fails with 400 bad_signature even if the signature is otherwise valid.

Order struct

struct Order {
    uint256 salt;
    address maker;
    address signer;
    address taker;
    uint256 tokenId;
    uint256 makerAmount;
    uint256 takerAmount;
    uint256 expiration;
    uint256 feeRateBps;
    uint8   side;
    uint8   signatureType;
}

Field semantics

FieldMeaning
saltClient-chosen uint256 for replay protection.
makerAddress of the funds source. For SignatureType.EOA this equals signer. For SignatureType.VAULT this is the user’s vault address.
signerAddress that signs. Always an EOA.
taker0x0000…0000 for a public order; a specific address to restrict.
tokenIdERC-1155 position ID.
makerAmountMaximum quantity of the maker asset sold.
takerAmountMinimum quantity of the taker asset received.
expirationUnix seconds after which the order is void. Recommended: 0 (no expiry) — settlement is asynchronous (off-chain match → on-chain batch), and a too-short TTL surfaces as MatchFailed(OrderExpired) (selector 0xc56873ba) when the on-chain block timestamp passes the deadline mid-flight. The contract skips the expiry check entirely when expiration == 0.
feeRateBpsSet to 0 — taker fees are computed and charged on-chain by the exchange via the quadratic curve, independent of this field.
side0 = BUY, 1 = SELL.
signatureType0 = EOA (signer==maker, EOA holds USDC directly), 1 = VAULT (signer is EOA, maker is their vault clone). Production default: 1 — every retail flow is vault-backed.

Signature type: EOA vs VAULT

  • signer = your wallet EOA
  • maker = VaultFactory.vaultOf(signer) (must match on-chain)
  • Funds source: user’s vault contract
  • On-chain check: vaultFactory.vaultOf(signer) == maker and ecrecover(digest, signature) == signer.

Amount semantics

Both makerAmount and takerAmount are 6-decimal wei (USDC-scale). tokenId-denominated quantities use the same scale: 1 outcome token = 1_000_000 wei.
SidemakerAmounttakerAmount
BUY (0)USDC notional you spend (price × qty, 6-dec)outcome qty you receive (6-dec)
SELL (1)outcome qty you sell (6-dec)USDC notional you receive (6-dec)
For BUY at price 0.42 and qty 2.0: makerAmount = 840_000, takerAmount = 2_000_000.

Signing example — TypeScript

import { Wallet, randomBytes, parseUnits } from 'ethers';

const domain = {
  name: 'PredictStreet',
  version: '1',
  chainId: 99999,
  verifyingContract: '0xc3c197e42AfE809a7f34D3a7eE6aDE0cF7613D2b',
};

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: 'feeRateBps', type: 'uint256' },
    { name: 'side', type: 'uint8' },
    { name: 'signatureType', type: 'uint8' },
  ],
};

// BUY 2 YES @ 0.42 USDC
const priceWei    = parseUnits('0.42', 6);     //   420_000
const qtyWei      = parseUnits('2',    6);     // 2_000_000
const notionalWei = (priceWei * qtyWei) / 1_000_000n; //   840_000

const order = {
  salt:          BigInt('0x' + randomBytes(32).toString('hex')),
  maker:         userVaultAddress,         // VaultFactory.vaultOf(signer)
  signer:        wallet.address,           // EOA
  taker:         '0x0000000000000000000000000000000000000000',
  tokenId:       yesTokenId,               // from /api/markets/{symbol}.yesTokenId
  makerAmount:   notionalWei,              // BUY → USDC notional
  takerAmount:   qtyWei,                   // BUY → outcome qty
  expiration:    0n,                       // 0 = no on-chain expiry (recommended)
  feeRateBps:    0n,                       // taker fee charged on-chain (quadratic)
  side:          0,                        // BUY
  signatureType: 1,                        // VAULT
};

const signature = await wallet.signTypedData(domain, types, order);

Signing example — Python

from eth_account import Account

domain = {
    "name": "PredictStreet",
    "version": "1",
    "chainId": 99999,
    "verifyingContract": "0xc3c197e42AfE809a7f34D3a7eE6aDE0cF7613D2b",
}
types = {
    "EIP712Domain": [
        {"name": "name", "type": "string"},
        {"name": "version", "type": "string"},
        {"name": "chainId", "type": "uint256"},
        {"name": "verifyingContract", "type": "address"},
    ],
    "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": "feeRateBps", "type": "uint256"},
        {"name": "side", "type": "uint8"},
        {"name": "signatureType", "type": "uint8"},
    ],
}

signed = Account.sign_typed_data(
    private_key,
    {"domain": domain, "primaryType": "Order", "types": types, "message": order_dict},
)
signature = signed.signature.hex()

Deriving the orderId

import { keccak256 } from 'ethers';

const sigBytes = ethers.getBytes(signature);
const orderId = keccak256(sigBytes);

Split / merge / convert-positions — different domain

Vault position operations (splitPosition, mergePositions, convertPositions) sign under the vault’s own EIP-712 domain, not the exchange’s. The domain name changes (PredictStreetVault) and verifyingContract is the user’s per-user EIP-1167 clone — NOT the factory, NOT an exchange.
const vaultDomain = {
  name: 'PredictStreetVault',
  version: '1',
  chainId: 99999,
  verifyingContract: userVaultAddress, // VaultFactory.vaultOf(signer)
};
The struct layout, kind field (0 = binary, 1 = neg-risk), and the full dual-signature flow (owner + backend co-sig) are documented on Contracts → Vaults → EIP-712 domain.

Common signing mistakes

  1. Wrong verifyingContract for the market — binary vs neg-risk. Both exchanges share name + version but have distinct addresses, so the domain separators are distinct. Read negRiskEligible off GET /api/markets/{symbol} to pick.
  2. Using the Order domain for split / merge / convertPositions — those operations sign under the PredictStreetVault domain with verifyingContract = the user’s vault clone, not the exchange. See the section above.
  3. signer ≠ your API key’s associatedWallet — backend impersonation check rejects.
  4. VAULT mode with wrong makermaker must equal VaultFactory.vaultOf(signer). The frontend always pre-resolves the vault via VaultFactory.vaultOf(eoa) before signing; if the user has no vault yet, vaultOf returns 0x0 — call VaultFactory.createVault(eoa) first.
  5. feeRateBps != 0 — taker fees are charged on-chain by the exchange’s quadratic curve; the field on the order is ignored beyond the signature digest, so any non-zero value just makes your signature non-portable to other systems.
  6. expiration > 0 with a tight TTL — settlement is async, so a “5 minute TTL” easily expires between off-chain match and on-chain submit, surfacing as MatchFailed(OrderExpired) (selector 0xc56873ba). Use 0 unless you specifically need a hard TTL well above worst-case settlement latency (~30s on testnet).
  7. Reused salt — every order’s hash is single-use on-chain.

Server-side EOA → vault resolution

When you POST /api/orders/place, the backend recomputes the vault for your signer EOA via VaultFactory.vaultOf(signer) and overrides the maker field of the on-chain order to that resolved address before storage. This means:
  • The maker you send in the REST body must be the same vault, or the EIP-712 digest will not match.
  • The off-chain matcher and on-chain CTFExchange._verifyVault both perform the same vaultOf(signer) == maker check — passing one but not the other is impossible.
  • For SELL, the position lookup is keyed by the vault address (since the ERC-1155 lives there), not the EOA. This is automatic.