Documentation Index
Fetch the complete documentation index at: https://docs.adipredictstreet.com/llms.txt
Use this file to discover all available pages before exploring further.
Every order, withdrawal, split, merge, and convert-positions request
requires an EIP-712 typed-data
signature. Different operations sign under different domains:
| 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 |
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
const domain = {
name: 'PredictStreet',
version: '1',
chainId: 36900,
verifyingContract: '0x3b32619897ae40C79b7086a0EB3F985077e7Fed7', // CTFExchange (binary)
};
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 (0x3b32619897ae40C79b7086a0EB3F985077e7Fed7).
- Neg-risk markets →
PredictStreetNegRiskCtfExchange (0x65A068b3C1C3088B1B23499A6104045f2b661B3e).
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
struct Order {
uint256 salt;
address maker;
address signer;
address taker;
uint256 tokenId;
uint256 makerAmount;
uint256 takerAmount;
uint256 expiration;
uint256 feeRateBps;
uint8 side;
uint8 signatureType;
}
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
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.
signer = maker = your wallet EOA
- Funds source: the EOA itself (holds USDC directly)
- On-chain check:
ecrecover(digest, signature) == signer == maker.
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.
| 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) |
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:
calculatePrice(BUY) = makerAmount × 1e6 / takerAmount (floor)
calculatePrice(SELL) = takerAmount × 1e6 / makerAmount (floor)
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:
const priceWei = parseUnits('0.52', 6); // 520_000
const qtyWei = parseUnits('67.307692', 6); // 67_307_692
const product = priceWei * qtyWei; // 34_999_999_840_000
// WRONG — FLOOR. Placement REJECTS with order_signed_with_floor_notional.
const notionalFloor = product / 1_000_000n; // 34_999_999
// CORRECT — CEIL.
const notionalCeil = (product + 999_999n) / 1_000_000n; // 35_000_000
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
import { Wallet, randomBytes, parseUnits } from 'ethers';
const domain = {
name: 'PredictStreet',
version: '1',
chainId: 36900,
verifyingContract: '0x3b32619897ae40C79b7086a0EB3F985077e7Fed7',
};
const types = {
Order: [
{ name: 'salt', type: 'uint256' },
{ name: 'maker', type: 'address' },
{ name: 'signer', type: 'address' },
{ name: 'taker', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'makerAmount', type: 'uint256' },
{ name: 'takerAmount', type: 'uint256' },
{ name: 'expiration', type: 'uint256' },
{ name: 'feeRateBps', type: 'uint256' },
{ name: 'side', type: 'uint8' },
{ name: 'signatureType', type: 'uint8' },
],
};
// BUY 2 YES @ 0.42 USDC
const priceWei = parseUnits('0.42', 6); // 420_000
const qtyWei = parseUnits('2', 6); // 2_000_000
// CEIL on BUY notional — sub-cent +1-wei guard against the
// matcher's per-fill CEIL math overflowing your signed cap. See
// "Rounding rule" above for the full rationale. FLOOR-signed BUY
// orders are rejected at placement with
// `order_signed_with_floor_notional`.
const product = priceWei * qtyWei;
const notionalWei = (product + 999_999n) / 1_000_000n; // 840_000
// CRITICAL — feeRateBps MUST equal `market.feeTakerBps` from
// `GET /api/markets/{symbol}`. The platform reconstructs the canonical
// Order struct with the live taker fee, hashes it, and recovers the
// signer against THAT digest. Signing with `0n` (or any other constant)
// produces a different hash → recovery returns the wrong address →
// `POST /api/orders/place` rejects with `bad_signature`. There is no
// override; the fee is per-market and partner SDKs must thread it
// through to the signer.
const feeRateBps = BigInt(market.feeTakerBps);
const order = {
salt: BigInt('0x' + randomBytes(32).toString('hex')),
maker: userVaultAddress, // VaultFactory.vaultOf(signer)
signer: wallet.address, // EOA
taker: '0x0000000000000000000000000000000000000000',
tokenId: yesTokenId, // from /api/markets/{symbol}.yesTokenId
makerAmount: notionalWei, // BUY → USDC notional
takerAmount: qtyWei, // BUY → outcome qty
expiration: 0n, // 0 = no on-chain expiry (recommended)
feeRateBps, // = market.feeTakerBps (see above)
side: 0, // BUY
signatureType: 1, // VAULT
};
const signature = await wallet.signTypedData(domain, types, order);
Signing example — Python
from eth_account import Account
domain = {
"name": "PredictStreet",
"version": "1",
"chainId": "36900",
"verifyingContract": "0x3b32619897ae40C79b7086a0EB3F985077e7Fed7",
}
types = {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
],
"Order": [
{"name": "salt", "type": "uint256"},
{"name": "maker", "type": "address"},
{"name": "signer", "type": "address"},
{"name": "taker", "type": "address"},
{"name": "tokenId", "type": "uint256"},
{"name": "makerAmount", "type": "uint256"},
{"name": "takerAmount", "type": "uint256"},
{"name": "expiration", "type": "uint256"},
{"name": "feeRateBps", "type": "uint256"},
{"name": "side", "type": "uint8"},
{"name": "signatureType", "type": "uint8"},
],
}
signed = Account.sign_typed_data(
private_key,
{"domain": domain, "primaryType": "Order", "types": types, "message": order_dict},
)
signature = signed.signature.hex()
Deriving the orderId
import { keccak256 } from 'ethers';
const sigBytes = ethers.getBytes(signature);
const orderId = keccak256(sigBytes);
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.
const vaultDomain = {
name: 'PredictStreetVault',
version: '1',
chainId: 36900,
verifyingContract: userVaultAddress, // VaultFactory.vaultOf(signer)
};
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
- 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.
- 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.
signer ≠ your API key’s associatedWallet — backend
impersonation check rejects.
- VAULT mode with wrong
maker — maker 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.
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.
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 in production).
- Reused
salt — every order’s hash is single-use on-chain.
- 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.