| Operation | Domain name | verifyingContract | Struct |
|---|---|---|---|
| Place order (binary market) | PredictStreet | CTFExchange address | Order (this page) |
| Place order (neg-risk market) | PredictStreet | PredictStreetNegRiskCtfExchange address | Order (this page) |
| Withdraw USDC | PredictStreet (same as order) | CTFExchange address | WithdrawERC20 — see Withdrawals EIP-712 |
| Split / merge / convert positions | PredictStreetVault | per-user vault clone address (VaultFactory.vaultOf(signer)), NOT the factory | SplitPosition / MergePositions — see Contracts → Vaults |
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
Order struct
Field semantics
| Field | Meaning |
|---|---|
salt | Client-chosen uint256 for replay protection. |
maker | Address of the funds source. For SignatureType.EOA this equals signer. For SignatureType.VAULT this is the user’s vault address. |
signer | Address that signs. Always an EOA. |
taker | 0x0000…0000 for a public order; a specific address to restrict. |
tokenId | ERC-1155 position ID. |
makerAmount | Maximum quantity of the maker asset sold. |
takerAmount | Minimum quantity of the taker asset received. |
expiration | Unix 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. |
feeRateBps | Must 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. |
side | 0 = BUY, 1 = SELL. |
signatureType | 0 = 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
- SignatureType.VAULT (production default)
- SignatureType.EOA (integrator / MM)
signer= your wallet EOAmaker=VaultFactory.vaultOf(signer)(must match on-chain)- Funds source: user’s vault contract
- On-chain check:
vaultFactory.vaultOf(signer) == makerandecrecover(digest, signature) == signer.
Amount semantics
BothmakerAmount and takerAmount are 6-decimal wei (USDC-scale).
tokenId-denominated quantities use the same scale: 1 outcome token =
1_000_000 wei.
| Side | makerAmount | takerAmount |
|---|---|---|
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) |
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:- BUY — round the USDC notional UP (
CEIL). Adds at most 1 wei extra USDC (sub-cent), guarantees the chain-sidecalculatePrice(BUY) >= priceWeiand 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 withorder_signed_with_floor_notional— they sit on the book with ZERO headroom and overflow the chain invariantMakingGtRemainingon 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 pushcalculatePrice(SELL)up by 1 wei and trip the BURN-boundarypriceA + priceB <= ONEcheck. makerAmountitself on SELL is the exact outcome-token quantity — no rounding (makerAmount = qtyWei).
Worked example — boundary tuple
BUY at price 0.52, qty 67.307692:
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.
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
- Wrong
verifyingContractfor the market — binary vs neg-risk. Both exchanges sharename+versionbut have distinct addresses, so the domain separators are distinct. ReadnegRiskEligibleoffGET /api/markets/{symbol}to pick. - Using the Order domain for split / merge / convertPositions —
those operations sign under the
PredictStreetVaultdomain withverifyingContract= the user’s vault clone, not the exchange. See the section above. signer≠ your API key’sassociatedWallet— backend impersonation check rejects.- VAULT mode with wrong
maker—makermust equalVaultFactory.vaultOf(signer). The frontend always pre-resolves the vault viaVaultFactory.vaultOf(eoa)before signing; if the user has no vault yet,vaultOfreturns0x0— callVaultFactory.createVault(eoa)first. feeRateBps≠ livemarket.feeTakerBps— the matcher’s quadratic curve readsfeeRateBpsoff the signed order, and the backend rejects withbad_signaturewhen the value differs fromEffectiveFeeService.resolveForMarket(symbol)(admin-published rate at settle time). Hard-coding0or a stale value across fee-period transitions is the usual cause. Always re-fetchGET /api/markets/{symbol}.feeTakerBpson the same request that builds the digest, sign with that exact integer, and echo it asfeeRateBpsin the request body so the server can sanity-check against its own resolved value before verifying the signature.expiration > 0with a tight TTL — settlement is async, so a “5 minute TTL” easily expires between off-chain match and on-chain submit, surfacing asMatchFailed(OrderExpired)(selector0xc56873ba). Use0unless you specifically need a hard TTL well above worst-case settlement latency (~30s in production).- Reused
salt— every order’s hash is single-use on-chain. - FLOOR-rounded BUY
makerAmount—(priceWei * qtyWei) / 1_000_000nin JS / Python uses integer FLOOR division. For boundary tuples (e.g.0.52 × 67.307692) FLOOR produces34_999_999, CEIL produces35_000_000. The platform rejects FLOOR-signed BUY orders at placement withorder_signed_with_floor_notionalso 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’sdetailscarries the exactexpectedCeilMakerAmountWeiyou 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
makeryou 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._verifyVaultboth perform the samevaultOf(signer) == makercheck — 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.