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. The backend returns 400 bad_signature; on-chain it reverts at _verifyOrderSignature / _verifyVault.

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 → PredictStreetNegRiskCtfExchange (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 400 bad_signature even if the signature is otherwise valid.

Order struct

Field semantics

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. 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:
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:
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

Deriving 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 per-user EIP-1167 clone — NOT the factory, NOT an exchange.
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 on testnet).
  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.