Thassa.

Protocol

Gasless orders

Thassa users never pay gas and never grant custody. An order is EIP-712 typed data; its funding is an EIP-3009 receiveWithAuthorization paying the markets contract; and a single signature commits to both. The backend relayer batches signed orders onchain via placeOrdersBatch and pays the gas.

The Order type

The order every client signs against, pinned by the protocol:

SignedOrder (Solidity)
struct SignedOrder {
    uint256 marketId;
    uint8   side;            // Side: 0 = YES, 1 = NO
    uint8   price;           // cents, 1..99 (limit price the maker pays per share)
    uint80  shares;          // number of $1 shares
    uint256 maxCost;         // token units the signer authorizes at most (escrow + fee headroom)
    uint256 affiliatePostId; // 0 = none
    uint64  expiry;          // unix seconds
    uint256 nonce;           // per-maker sequential
    address maker;
}
EIP-712 domain + type
// EIP-712 domain
{ name: "ThassaMarkets", version: "1", chainId, verifyingContract }

// EIP-712 type (verbatim)
Order(uint256 marketId,uint8 side,uint8 price,uint80 shares,uint256 maxCost,uint256 affiliatePostId,uint64 expiry,uint256 nonce,address maker)
  • maxCost caps what the order may pull: escrow (price × shares) plus taker-fee headroom. The EIP-3009 authorization’s value covers it.
  • nonce is per-maker sequential (read it from nonces(maker) or GET /trade-api/v1/balance) and prevents replay.
  • expiry bounds how long the signed order is valid.
  • affiliatePostId credits the post that routed the trade with 5% of collected taker fees; 0 = none.

The funding authorization

Funding rides an EIP-3009 payload for the payment token — from = order.maker, to = the markets contract, always:

Auth3009 (Solidity)
struct Auth3009 { // receiveWithAuthorization payload, from = order.maker, to = markets contract
    uint256 value;
    uint256 validAfter;
    uint256 validBefore;
    bytes32 authNonce;
    uint8 v; bytes32 r; bytes32 s;
}

The signature carriage convention

The one rule to get right

SignedOrder carries no signature fields. For order placement, auth.authNonce MUST equal orderDigest(order) — the maker’s EIP-712 typed-data digest under domain {ThassaMarkets, 1, chainId, contract} — so the single EIP-3009 signature commits to both the payment and the order.

Clients sign one thing: the ReceiveWithAuthorization typed data whose nonce is the order digest. Because the digest covers every order field, tampering with any of them changes the digest, which invalidates the payment authorization — the order and its funding are inseparable.

  • orderDigest(SignedOrder) view is exposed onchain, so any client can cross-check its own hashing.
  • The relayer recomputes and validates the binding before batching — a mismatched authNonce is rejected at the door.
  • Settlement authorizations (settleMarketWithAuth) are the exception: their nonce is a random 32 bytes; only value ≥ settlementFee is required.
  • New-market opening orders are signed with marketId = 0 and bound to the assigned id on creation.

Signing in practice (viem)

Compute the order digest, then sign the token’s ReceiveWithAuthorization with that digest as the nonce:

signing.ts
import { hashTypedData } from "viem";

const orderDigest = hashTypedData({
  domain: {
    name: "ThassaMarkets",
    version: "1",
    chainId,
    verifyingContract: MARKETS_CONTRACT,
  },
  types: {
    Order: [
      { name: "marketId",        type: "uint256" },
      { name: "side",            type: "uint8"   },
      { name: "price",           type: "uint8"   },
      { name: "shares",          type: "uint80"  },
      { name: "maxCost",         type: "uint256" },
      { name: "affiliatePostId", type: "uint256" },
      { name: "expiry",          type: "uint64"  },
      { name: "nonce",           type: "uint256" },
      { name: "maker",           type: "address" },
    ],
  },
  primaryType: "Order",
  message: order,
});

// The ONE thing you sign: the payment token's ReceiveWithAuthorization
// typed data, with nonce = orderDigest.
const signature = await wallet.signTypedData({
  domain: paymentTokenDomain, // the token's own EIP-712 domain
  types: {
    ReceiveWithAuthorization: [
      { name: "from",        type: "address" },
      { name: "to",          type: "address" },
      { name: "value",       type: "uint256" },
      { name: "validAfter",  type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce",       type: "bytes32" },
    ],
  },
  primaryType: "ReceiveWithAuthorization",
  message: {
    from: order.maker,
    to: MARKETS_CONTRACT,   // always the markets contract
    value: order.maxCost,
    validAfter: 0n,
    validBefore: BigInt(order.expiry),
    nonce: orderDigest,     // ← the signature carriage convention
  },
});

For the complete end-to-end example — building the payload and submitting it to POST /trade-api/v1/orders — see Trading API.

What the relayer does (and can’t do)

  • Queues signed orders and submits batches every ~2s (or 25 orders) via placeOrdersBatch(SignedOrder[], Auth3009[]) — batching is what amortizes gas to zero for users.
  • Only ever signs transactions to the allowlisted platform contracts and whitelisted methods. Never arbitrary calldata.
  • Validates server-side that every EIP-3009 authorization pays the markets contract before relaying.
  • Cannot forge orders: it holds no user keys, and every order’s funding is a signature only the maker could have produced.

Once batched, your order’s state moves QUEUED RESTING / PARTIAL / FILLED; cancels work the same way with a signed cancel. Prefer no relayer at all? Use the direct onchain path.