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: Using the wrong domain (name, version, or verifyingContract) produces a signature that recovers a different address than your signer. For orders, the backend rejects the request with bad_signature, and the exchange contract would reject the same signature with InvalidSignature(). For withdrawals, POST /api/withdrawals/request rejects it with HTTP 400 invalid_signature before anything is sent on-chain. For split / merge / convert, the backend co-signs without seeing your signature. If you relay the op through POST /api/me/positions/op-submit with an API key, it is rejected with HTTP 403 op_signer_mismatch; if you broadcast the vault call yourself, the vault contract rejects it with InvalidOwnerSignature().

Order-signing domain

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 (0x4074c225b296E1E556c565B0C3Ddba305E63E7c4).
  • Neg-risk markets → NegRiskCtfExchange (0x2eB97912c333963a21410Af1eF7E9a0aAB7631bf).
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 bad_signature even if the signature is otherwise valid.

Order struct

Field semantics

Signature type: always VAULT (1)

PredictStreet is vault-only. Every order (retail, integrator and market maker) must be signed with signatureType: 1 (VAULT):
  • signer = your wallet EOA (the wallet you authenticate as)
  • maker = your vault, VaultFactory.vaultOf(signer)
  • Funds source: your vault contract
  • On-chain check: ecrecover(digest, signature) == signer and vaultFactory.vaultOf(signer) == maker.

What the platform rebuilds

POST /api/orders/place (and each entry of /api/orders/place-batch) does not carry the signed struct. The platform rebuilds the Order from the REST body and your authenticated wallet, hashes it under the market’s domain (binary or neg-risk, per negRiskEligible), and checks that the signature recovers to your wallet. Every field you sign must match what it rebuilds: The REST body maker field is only an authorization check. It must be your wallet EOA or your vault; any other value is rejected with maker_mismatch (HTTP 401 on /api/orders/place; on /api/orders/place-batch only that entry is rejected). Either accepted value leaves the digest unchanged: the platform always verifies with maker = vaultOf(signer) and stores the order with your vault as maker. For SELL, the position lookup is keyed by the vault address, since the ERC-1155 tokens live there. This is automatic.
signatureType: 0 (EOA, maker == signer) is deprecated and is never accepted. There is no EOA-direct trading mode. The platform always rebuilds your order with maker = vaultOf(signer) and signatureType = 1 before recovering the signer. An order signed with signatureType: 0 or maker = <your EOA> therefore recovers a different address and is rejected with bad_signature. The exchange contract also rejects any signatureType other than 1.

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. 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, scaled to 18 decimals (ONE = 1e18):
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) is at least your limit price 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) above your limit price 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 AND keeps the matcher’s per-fill CEIL math from overflowing the maker’s signed cap on the closing tail fill (MakingGtRemaining). It is the correct shape, not a bug.

Worked example — boundary tuple

BUY at price 0.52, qty 67.307692:
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

Signing example — Python

Order ID

The orderId is a server-assigned UUID returned in the POST /api/orders/place response. It is an empty string when the order was rejected before it was recorded (validation, signature, fee or funds checks). An order the matching engine rejects after it was recorded (for example self_cross_blocked) comes back as REJECTED with its real orderId, so branch on status, not on orderId. You can’t derive it from the signature or the signed struct on the client. Use it for POST /api/orders/cancel and GET /api/orders/{id}. To match a response to your request, set clientOrderId on the place request. The response echoes it back on every outcome, including rejections and idempotent replays.

Order hash

The orderHash is the EIP-712 digest of the Order you signed, the same value the exchange contract emits in OrderFilled. You can compute it before sending, for example with ethers: TypedDataEncoder.hash(domain, types, orderStruct). It comes back lowercase as orderHash on every order read and on the order_placed WebSocket frame, and GET /api/orders/lookup?orderHash= finds the order by it, which helps when a place request timed out before you saw the orderId.

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 own vault address — NOT the factory, NOT an exchange.
Each operation signs its own struct: SplitPosition, MergePositions or ConvertPositions. Split and merge carry a kind field (0 = binary, 1 = neg-risk); ConvertPositions(bytes32 marketId,uint256 indexSet,uint256 amount,uint256 salt,uint256 deadline) has none. The struct layouts 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, not the exchange. See the section above.
  3. Signing with a key other than the wallet you authenticate as — the platform sets signer to your authenticated wallet: the API key’s associatedWallet, or the X-User-Wallet header value for multi_wallet keys. You don’t send signer in the request body. If the body maker is neither that wallet nor its vault, the request is rejected with maker_mismatch. Otherwise, a signature from any other key fails with bad_signature, and details.expected_signer is your authenticated wallet. details.recovered shows the key you signed with only if the struct you signed matches details.platform_canonical_struct field for field (including signer) and you used the market’s domain; otherwise recovered is an unrelated address, so compare the struct first.
  4. Signed maker = your EOA, or signatureType: 0 — the signed maker must equal VaultFactory.vaultOf(signer), which is your vault, and signatureType must be 1. There is no EOA-direct mode. The request-body maker may be your EOA or your vault, but the signature is always checked against your vault as maker. Read your vault address from GET /api/me/vault (vaultAddress, deployed) or call vaultOf(eoa) on-chain. The platform deploys vaults automatically (see Backend auto-deploy). Until yours is deployed, placement is rejected with vault_not_deployed before the signature is checked.
  5. feeRateBps ≠ live market.feeTakerBps — the platform rebuilds the struct with the market’s current taker fee at placement time. Hard-coding 0 or keeping a stale value across fee-period transitions is the usual cause. Re-fetch GET /api/markets/{symbol}.feeTakerBps right before you build the digest, sign with that exact integer, and echo it as feeRateBps in the request body. With the echo, a stale fee is rejected clearly as stale_fee_quote instead of a generic bad_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 on testnet).
  7. Reused salt — every order’s hash is single-use on-chain.
  8. nonce ≠ signed salt — the body’s nonce is the struct’s salt. Generate the salt once, sign with it, and send salt.toString() (base 10, not hex) as nonce. Two independently generated random values always fail with bad_signature.
  9. 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.

Debugging bad_signature

bad_signature is a business reject: POST /api/orders/place answers HTTP 201 with status: "REJECTED" and code: "bad_signature", and the body’s details carry:
  • recovered: the address your signature recovers to against the platform’s struct (null if the signature can’t be decoded).
  • expected_signer: your authenticated wallet.
  • platform_canonical_struct: the exact Order struct the platform hashed (see What the platform rebuilds).
The platform only accepts a signature over platform_canonical_struct, so sign exactly that struct. Compare it with the struct you signed, field by field:
  • A difference in maker, signer, taker, feeRateBps or signatureType means you signed the wrong value: sign the platform’s value (your vault, your authenticated wallet, the zero address, the market’s feeTakerBps, 1).
  • A difference in salt, tokenId, makerAmount / takerAmount, expiration or side means the struct you signed and the body you sent disagree (nonce, outcome, price / quantity, expiry, side), or your amounts were rounded differently (see the Rounding rule). Make them describe the same order, then re-sign.
  • If every field matches, check the domain, which the struct does not include: the binary vs neg-risk verifyingContract for the market’s negRiskEligible. Then check that you signed with your authenticated wallet’s key (mistake 3 above).
If recovered equals expected_signer, the struct and domain matched but the order’s non-zero expiration had already passed when it arrived. See also bad_signature diagnostic hints.