Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.adipredictstreet.com/llms.txt

Use this file to discover all available pages before exploring further.

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: 36900,
  verifyingContract: '0x3b32619897ae40C79b7086a0EB3F985077e7Fed7', // 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 (0x3b32619897ae40C79b7086a0EB3F985077e7Fed7).
  • Neg-risk markets → PredictStreetNegRiskCtfExchange (0x65A068b3C1C3088B1B23499A6104045f2b661B3e).
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.
feeRateBpsMust equal the live market’s feeTakerBps — read fresh from GET /api/markets/{symbol} on the same request that builds the digest. The matcher’s quadratic curve reads feeRateBps off the signed order, and the backend rejects with bad_signature when the value the client signed differs from EffectiveFeeService.resolveForMarket(symbol) (admin-published rate at settle time). Common bug: hard-coding 0 or a stale value across fee-period transitions.
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 — see rounding below)outcome qty you receive (6-dec)
SELL (1)outcome qty you sell (6-dec)USDC notional you receive (⌊price × qty⌋, 6-dec)
For BUY at price 0.42 and qty 2.0: makerAmount = 840_000, takerAmount = 2_000_000. (Clean-cent tuple — CEIL and FLOOR coincide. See the next section for the boundary cases that matter.)

Rounding rule — CEIL on BUY, FLOOR on SELL notional

The on-chain CTFExchange recomputes each side’s price by an INDEPENDENT floor-div:
calculatePrice(BUY)  = makerAmount × 1e6 / takerAmount   (floor)
calculatePrice(SELL) = takerAmount × 1e6 / makerAmount   (floor)
This is asymmetric and forces an asymmetric signing rule:
  • BUY — round the USDC notional UP (CEIL). Adds at most 1 wei extra USDC (sub-cent), guarantees the chain-side calculatePrice(BUY) >= priceWei and gives the matcher’s CEIL’d per-fill math the +1 wei of headroom it needs against your signed cap. FLOOR-signed BUY orders are rejected at placement with order_signed_with_floor_notional — they sit on the book with ZERO headroom and overflow the chain invariant MakingGtRemaining on the closing tail fill, which forces a different taker to absorb the revert.
  • SELL — round the USDC notional DOWN (FLOOR). CEIL’ing the SELL would push calculatePrice(SELL) up by 1 wei and trip the BURN-boundary priceA + priceB <= ONE check.
  • makerAmount itself on SELL is the exact outcome-token quantity — no rounding (makerAmount = qtyWei).
The asymmetric ceil/floor closes both the MINT and BURN cross-order boundary checks (LAO-LAT-007 incident reference) AND keeps the matcher’s per-fill CEIL math from overflowing the maker’s signed cap on the closing tail fill (MakingGtRemaining incident WC26D-GRPA-MEX-URU). It is the correct shape, not a bug.

Worked example — boundary tuple

BUY at price 0.52, qty 67.307692:
const priceWei = parseUnits('0.52', 6);        //         520_000
const qtyWei   = parseUnits('67.307692', 6);   //      67_307_692
const product  = priceWei * qtyWei;            // 34_999_999_840_000

// WRONG — FLOOR. Placement REJECTS with order_signed_with_floor_notional.
const notionalFloor = product / 1_000_000n;              // 34_999_999

// CORRECT — CEIL.
const notionalCeil  = (product + 999_999n) / 1_000_000n; // 35_000_000
The 1-wei difference is sub-cent and economically meaningless, but the chain math cares about every wei. Always sign notionalCeil for BUY.makerAmount.

Signing example — TypeScript

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

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

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
// CEIL on BUY notional — sub-cent +1-wei guard against the
// matcher's per-fill CEIL math overflowing your signed cap. See
// "Rounding rule" above for the full rationale. FLOOR-signed BUY
// orders are rejected at placement with
// `order_signed_with_floor_notional`.
const product     = priceWei * qtyWei;
const notionalWei = (product + 999_999n) / 1_000_000n; //   840_000

// CRITICAL — feeRateBps MUST equal `market.feeTakerBps` from
// `GET /api/markets/{symbol}`. The platform reconstructs the canonical
// Order struct with the live taker fee, hashes it, and recovers the
// signer against THAT digest. Signing with `0n` (or any other constant)
// produces a different hash → recovery returns the wrong address →
// `POST /api/orders/place` rejects with `bad_signature`. There is no
// override; the fee is per-market and partner SDKs must thread it
// through to the signer.
const feeRateBps = BigInt(market.feeTakerBps);

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,                              // = market.feeTakerBps (see above)
  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": "36900",
    "verifyingContract": "0x3b32619897ae40C79b7086a0EB3F985077e7Fed7",
}
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: 36900,
  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 ≠ live market.feeTakerBps — the matcher’s quadratic curve reads feeRateBps off the signed order, and the backend rejects with bad_signature when the value differs from EffectiveFeeService.resolveForMarket(symbol) (admin-published rate at settle time). Hard-coding 0 or a stale value across fee-period transitions is the usual cause. Always re-fetch GET /api/markets/{symbol}.feeTakerBps on the same request that builds the digest, sign with that exact integer, and echo it as feeRateBps in the request body so the server can sanity-check against its own resolved value before verifying the signature.
  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 in production).
  7. Reused salt — every order’s hash is single-use on-chain.
  8. FLOOR-rounded BUY makerAmount(priceWei * qtyWei) / 1_000_000n in JS / Python uses integer FLOOR division. For boundary tuples (e.g. 0.52 × 67.307692) FLOOR produces 34_999_999, CEIL produces 35_000_000. The platform rejects FLOOR-signed BUY orders at placement with order_signed_with_floor_notional so they never poison the book. Fix: use the CEIL formula (product + 999_999n) / 1_000_000n. See the Rounding rule section above. The reject envelope’s details carries the exact expectedCeilMakerAmountWei you should re-sign with, so you don’t need to recompute the formula client-side.

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.