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
Order struct
Field semantics
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.
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:- 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 on testnet).- 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.