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
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) == signerandvaultFactory.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.
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, scaled to 18 decimals (ONE = 1e18):
- BUY — round the USDC notional UP (
CEIL). Adds at most 1 wei extra USDC (sub-cent), guarantees the chain-sidecalculatePrice(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 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)above your limit price and trip the BURN-boundarypriceA + priceB <= ONEcheck. makerAmountitself on SELL is the exact outcome-token quantity — no rounding (makerAmount = qtyWei).
MakingGtRemaining). It is the correct shape, not a bug.
Worked example — boundary tuple
BUY at price 0.52, qty 67.307692:
notionalCeil
for BUY.makerAmount.
Signing example — TypeScript
Signing example — Python
Order ID
TheorderId 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
TheorderHash 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.
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
- 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, not the exchange. See the section above. - Signing with a key other than the wallet you authenticate as —
the platform sets
signerto your authenticated wallet: the API key’sassociatedWallet, or theX-User-Walletheader value formulti_walletkeys. You don’t sendsignerin the request body. If the bodymakeris neither that wallet nor its vault, the request is rejected withmaker_mismatch. Otherwise, a signature from any other key fails withbad_signature, anddetails.expected_signeris your authenticated wallet.details.recoveredshows the key you signed with only if the struct you signed matchesdetails.platform_canonical_structfield for field (includingsigner) and you used the market’s domain; otherwiserecoveredis an unrelated address, so compare the struct first. - Signed
maker= your EOA, orsignatureType: 0— the signedmakermust equalVaultFactory.vaultOf(signer), which is your vault, andsignatureTypemust be1. There is no EOA-direct mode. The request-bodymakermay be your EOA or your vault, but the signature is always checked against your vault asmaker. Read your vault address fromGET /api/me/vault(vaultAddress,deployed) or callvaultOf(eoa)on-chain. The platform deploys vaults automatically (see Backend auto-deploy). Until yours is deployed, placement is rejected withvault_not_deployedbefore the signature is checked. feeRateBps≠ livemarket.feeTakerBps— the platform rebuilds the struct with the market’s current taker fee at placement time. Hard-coding0or keeping a stale value across fee-period transitions is the usual cause. Re-fetchGET /api/markets/{symbol}.feeTakerBpsright before you build the digest, sign with that exact integer, and echo it asfeeRateBpsin the request body. With the echo, a stale fee is rejected clearly asstale_fee_quoteinstead of a genericbad_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. nonce≠ signedsalt— the body’snonceis the struct’ssalt. Generate the salt once, sign with it, and sendsalt.toString()(base 10, not hex) asnonce. Two independently generated random values always fail withbad_signature.- 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.
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 (nullif the signature can’t be decoded).expected_signer: your authenticated wallet.platform_canonical_struct: the exactOrderstruct the platform hashed (see What the platform rebuilds).
platform_canonical_struct,
so sign exactly that struct. Compare it with the struct you signed,
field by field:
- A difference in
maker,signer,taker,feeRateBpsorsignatureTypemeans you signed the wrong value: sign the platform’s value (your vault, your authenticated wallet, the zero address, the market’sfeeTakerBps,1). - A difference in
salt,tokenId,makerAmount/takerAmount,expirationorsidemeans 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
verifyingContractfor the market’snegRiskEligible. Then check that you signed with your authenticated wallet’s key (mistake 3 above).
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.