> For the complete documentation index, see [llms.txt](https://mute-sh.gitbook.io/mute.sh/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mute-sh.gitbook.io/mute.sh/reference/maker-protocol-spec.md).

# Maker Protocol Spec (/v2/ws/rfq)

{% hint style="danger" %}
**Status: authoritative, code-verified.** This document describes the `/v2/ws/rfq` wire contract **as implemented today** by the live v2 relay. Every claim below has been checked directly against the running relay's behavior and its typed wire contracts.

**This supersedes the protocol sections of** [**`maker-guide.md`**](/mute.sh/integration-guides/maker-guide.md) **and** [**`ws-events.md`**](/mute.sh/reference/ws-events.md) **§3.** Those documents describe the **v1** relay, which speaks a different handshake and a different `ParlayQuote` shape. Concretely, wherever you see:

* a `hello` / `welcome` handshake,
* `openRfqs` backfill on connect,
* message types `rfq` / `quote` / `lastlook` / `lastlook:ack` / `quote:reject`,
* a `ParlayQuote` that includes `spreadBps`,

— that is v1. It does not apply to the deployed v2 relay (`parlay.mute.sh/v2`). This document, plus the reference maker bots and the `@mutesh/parlay-maker` package, are v2-correct.
{% endhint %}

**Audience:** a market-maker engineer implementing a client from scratch (any language), or auditing the `@mutesh/parlay-maker` reference SDK against the wire.

***

## Table of contents

1. [Scope & status](#0-scope--status)
2. [Connection & transport](#1-connection--transport)
3. [Auth handshake](#2-auth-handshake)
4. [RFQ lifecycle, from the maker's view](#3-rfq-lifecycle-from-the-makers-view)
5. [Server → maker messages](#4-server--maker-messages)
6. [Maker → server messages](#5-maker--server-messages)
7. [Combo identity: condition\_id, position ids, and conflict checks](#6-combo-identity-condition_id-position-ids-and-conflict-checks)
8. [Winner selection](#7-winner-selection-how-to-win-rfqs)
9. [Standing allowance (EIP-2612)](#8-standing-allowance-eip-2612)
10. [Error model](#9-error-model)
11. [Cashout (novation) maker sub-protocol](#10-cashout-novation-maker-sub-protocol)
12. [EIP-712 domains & type tables (appendix)](#11-eip-712-domains--type-tables-appendix)
13. [REST equivalents](#12-rest-equivalents-brief)

***

## 0. Scope & status

This spec covers the **maker channel**, `/ws/rfq` (mounted publicly at `parlay.mute.sh/v2/ws/rfq` — the production reverse proxy strips the `/v2` prefix before the request reaches the relay, so the relay itself only ever sees `/ws/rfq`). There is a separate, independent taker channel (`/ws/taker`) that is out of scope here except where a maker needs to understand what the taker sees (§10, cashout acceptance).

Source of truth, in order of authority:

1. The running v2 relay logic (routes, WS handlers, validation) — this is the deployed behavior this document describes.
2. The typed wire contracts and pure state machines the relay imports (RFQ engine, combo identity, fee model, cashout book).
3. A working, v2-correct reference maker bot (cashout side) maintained alongside the relay. **A same-named "mm-sdk" helper elsewhere in the reference code is NOT a v2 reference** — it is a v1 SDK; see the warning banner above.
4. The `@mutesh/parlay-maker` package — the packaged SDK extracted alongside this doc, built to match everything below.

***

## 1. Connection & transport

* **Endpoint:** `wss://parlay.mute.sh/v2/ws/rfq` in production; `ws://localhost:4001/ws/rfq` for a local relay (default `PORT=4001`/`BIND_HOST=127.0.0.1`).
* **Framing:** JSON text frames only. Every message is a flat object with a `type` discriminator. A frame that isn't valid JSON gets `{type:"RFQ_ERROR", code:"INVALID_MESSAGE", error:"invalid JSON"}` back — the socket is **not** closed for this.
* **Two independent channels:** `/ws/rfq` (maker) and `/ws/taker` (taker snapshot push). They are fully separate WS upgrades with separate session maps — a maker connection never sees taker-only messages and vice versa. This document is entirely about `/ws/rfq`.
* **Auth deadline:** on `open`, if the socket is a maker socket, the server starts a timer for `V2_WS_AUTH_MS` (env-configurable, default **30 000 ms**). If no *successful* `auth` has landed by then, the socket is closed with code `1008`, reason `"auth timeout"`. The timer is cleared only on a **successful** auth — an *unsuccessful* auth attempt (bad key, bad identity) does **not** reset or cancel this timer, so a maker that keeps failing auth still gets dropped at the 30s mark. See §2 for the important nuance that a bad API key does not close the socket immediately.
* **Heartbeat:** every `V2_HEARTBEAT_MS` (default **30 000 ms**) the server iterates all maker sessions and either (a) closes the ones that have been silent for more than `V2_STALE_MS` (default **120 000 ms**) with code `1001` reason `"stale"`, or (b) sends them `{type:"ping", payload:"rfq"}`. **Any** authenticated message from the maker — not just a `ping` reply — resets the staleness clock. A maker that is actively quoting or cancelling never needs to send its own `ping`s to stay alive, but sending one periodically is cheap insurance if your integration might go quiet between RFQs.
* **Disconnect:** on `close`, the server deletes the maker's session immediately. There is no session persistence across reconnects — a maker that reconnects must send a fresh `auth` and will not receive any RFQs it missed while disconnected (see the "no backfill" note in §4).

***

## 2. Auth handshake

The very first message a maker socket must send is `auth`. Every other message before a successful `auth` gets `{type:"RFQ_ERROR", code:"UNAUTHENTICATED", error:"send auth first"}`.

### Client → server: `auth`

```jsonc
{
  "type": "auth",
  "auth": { "apiKey": "<your-key>" },                 // must === the relay's MM_API_KEY
  "identity": {
    "maker_address": "0xYourMakerEOA",                // the on-chain owner of the collateral
    "signer_address": "0xYourSignerEOA",               // who actually signs (may equal maker_address)
    "signature_type": 0                                 // ONLY 0 (EOA) is implemented
  },
  "last_look": false,                                   // opt-in; default false (see §3, §4)
  "approval": {                                          // OPTIONAL EIP-2612 permit — see §8
    "value": "1606938044258990275541962092341162602522202993782792835301376",
    "deadline": "1750000000",
    "v": 28, "r": "0x…", "s": "0x…"
  }
}
```

`auth.secret`/`auth.passphrase` are declared on the wire type but are **not read anywhere by the relay** — only `auth.apiKey` is checked. Don't rely on them.

### Server → client: `auth` (response)

**Success:**

```jsonc
{ "type": "auth", "success": true, "address": "0xYourMakerEOA", "role": "maker" }
```

`address` is the checksummed `maker_address`. From this point the socket's session is bound to this maker for the lifetime of the connection.

**Failure** — several distinct cases, all replying with `{type:"auth", success:false, error:"..."}` and **none of them closing the socket**:

| Condition                                                                   | `error` message                                                                                                                                              |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `auth.apiKey` missing                                                       | `"apiKey required"`                                                                                                                                          |
| `auth.apiKey !== MM_API_KEY`                                                | `"invalid apiKey"`                                                                                                                                           |
| `maker_address`/`signer_address` missing or not a valid `0x` + 40 hex chars | `"maker_address and signer_address required"`                                                                                                                |
| `signature_type` is `1`, `2`, or `3`                                        | the `{type:"auth", ...}` response is NOT sent — instead `{type:"RFQ_ERROR", code:"INVALID_IDENTITY", error:"only EOA (signature_type=0) supported"}` is sent |

{% hint style="warning" %}
**A bad API key does NOT close the socket.** The server replies `{success:false}` and stops processing — the connection stays open. The only thing that eventually closes an unauthenticated socket is the 30s auth-timeout from §1. This is the opposite of what `maker-guide.md` §4 claims ("a bad key closes the socket immediately") — that claim describes v1's `hello` handshake, not v2's `auth`. If you retry `auth` with a corrected key within the 30s window, the retry succeeds normally.
{% endhint %}

### Identity model

* **`maker_address`** is the on-chain owner: the address that (a) posts `makerCollateral` into the escrow, (b) must hold a standing USDC allowance (§8), and (c) is the address the server reports in error messages and the `RFQ_TRADE` tape.
* **`signer_address`** is who actually produces the EIP-712 signature over `ParlayQuote`. In the overwhelming common case these are the same EOA — but the wire protocol keeps them distinct fields throughout (auth, quote, quote-cancel, confirmation-response) so a maker *could* run a hot signer key distinct from the cold collateral-holding key. As of this writing only `signature_type: 0` (a plain EOA `ecrecover`) is supported for either role — `1`/`2`/`3` (reserved for e.g. contract-wallet signatures) are rejected with `INVALID_IDENTITY` everywhere they're checked.
* **Session-bound identity.** Once authed over WS, every subsequent `RFQ_QUOTE` you send is checked against the session's `maker_address`/`signer_address` — you cannot claim a different identity mid-connection. The WS handler doesn't even read maker/signer off the quote body — it always uses the session's identity — so this can't diverge on the current client-side code paths. The parallel `ADDRESS_MISMATCH` check *is* load-bearing for the REST fallback (§12), where identity comes from the request body instead of a session.
* **`last_look`** is a per-session boolean set once at `auth` time and cannot be changed without reconnecting. Default is `false` (no last-look). See §3/§4 for what opting in means and its very tight (1s default) response window.

***

## 3. RFQ lifecycle, from the maker's view

The relay runs a small state machine per RFQ:

```
CREATED → COLLECTING_QUOTES → AWAITING_REQUESTER_ACCEPTANCE
                                      │
                                      ├─(winner has last_look=false)──────────────▶ EXECUTING ─▶ FILLED
                                      │
                                      └─(winner has last_look=true)──▶ AWAITING_MAKER_CONFIRMATION
                                                                              │
                                                                    CONFIRM ──┼──▶ EXECUTING ─▶ FILLED
                                                                              │
                                                            DECLINE/timeout ──┴──▶ (fallback to next-best,
                                                                                     back to AWAITING_REQUESTER_ACCEPTANCE)
                                                                                     or REJECTED if none left
```

Terminal statuses: `FILLED`, `FAILED`, `EXPIRED`, `CANCELED`, `REJECTED`. From the maker's point of view, the sequence is:

1. A taker creates an RFQ (via REST `POST /v1/rfq` or the taker WS channel). Before doing anything else, the relay runs the full combo-identity and conflict-check pipeline described in §6 — a malformed or contradictory combo is rejected here and never reaches any maker. Once it passes, the relay opens a **quote window** of `quoteWindowMs` (default **400 ms**) and immediately broadcasts `RFQ_REQUEST` to **every** connected, authenticated maker.
2. Each interested maker prices it and sends back `RFQ_QUOTE` before the window closes. Late quotes are rejected with `SUBMISSION_WINDOW_CLOSED` (§5, §9).
3. When the window closes, the relay picks the best live quote (`pickBest`, §7) and moves to `AWAITING_REQUESTER_ACCEPTANCE`, notifying the taker. This step does **not** involve the makers at all — no message is sent to any maker at this point.
4. The taker accepts (signs `ParlayOrder` + `Permit` against the winning quote's hash). *Now* the winning maker's `last_look` flag matters:
   * **`last_look === false` (the default):** the relay proceeds straight to `EXECUTING` and submits the on-chain transaction. The winning maker is **not asked for permission** — its earlier signed `RFQ_QUOTE` is binding.
   * **`last_look === true`:** the relay sends that maker (and only that maker) a `RFQ_CONFIRMATION_REQUEST` and waits up to `confirm_by` (§4) for a `CONFIRM`/`DECLINE`.
5. On successful execution the relay broadcasts `RFQ_EXECUTION_UPDATE` (to the winner) and `RFQ_TRADE` (to **all** connected makers, win or lose — the public tape) as the transaction is submitted and then confirmed on-chain. This confirmation-of-execution push is immediate — it happens the moment the trade fills, not later. It is a **separate** event from settlement: a filled parlay's legs still have to resolve and its payout still has to be claimed, which can happen hours or longer afterward, and — unlike execution — the relay does **not** proactively push a general "your parlay settled" notice to anyone. The only settlement-adjacent push that exists at all is scoped narrowly to the cashout offer book (§10) for takers who are actively shopping a cashout, not a general fill-to-settlement notification stream. Do not build a maker integration that assumes it will be told when a parlay it won ultimately settles — it won't be, over this channel.

A maker that never opts into last-look only ever needs to implement steps 1–2 and consume the tape in step 5 (plus quote-cancel, if it wants to withdraw stale liquidity).

***

## 4. Server → maker messages

Every payload below is exactly what the relay sends — no fields are invented.

### `RFQ_REQUEST`

Broadcast to every connected, authed maker the instant an RFQ opens (and after it has already passed every conflict check in §6). Full shape:

```jsonc
{
  "type": "RFQ_REQUEST",
  "rfq_id": "rfqv2_1a2b3c…",
  "requestor_public_id": "0xTakerEOA",
  "leg_position_ids": ["#5160", "#420"],       // canonical, SORTED (see §6)
  "condition_id": "0xabc…",                     // == legsHash you must sign against (§6)
  "yes_position_id": "0x…",
  "no_position_id": "0x…",
  "direction": "BUY",                           // only value in production
  "side": "YES",                                // only value in production
  "requested_size": { "unit": "notional", "value_e6": "25000000" },  // GROSS stake, 6dp
  "submission_deadline": 1751234567890,         // unix ms — quote window close
  "escrow_terms": {                             // ALWAYS populated on create — this is what you price against
    "chain_id": 998,
    "escrow": "0x551D4A7EdB71583C7C12900128727D857e308925",
    "taker": "0xTakerEOA",
    "builder": "0x0000000000000000000000000000000000000000",
    "taker_collateral_e6": "25000000",          // == requested_size.value_e6 (gross)
    "net_stake_e6": "24750000",                 // gross − protocol fee − builder fee — PRICE AGAINST THIS
    "protocol_fee_bps": 100,
    "builder_fee_bps": 0,
    "spread_bps": 202                            // FYI ONLY — see the note below; you do not sign this
  }
}
```

**Leg identity, briefly.** A `leg_position_id` is the wire form `"#${outcomeId*10+sideIndex}"`; `"#5160"` parses back to `{outcomeId:516, sideIndex:0}`. The corresponding HIP-4 spot coin for pricing is `"#${outcomeId*10}"`. `leg_position_ids` on the wire is always the server's canonical sort order, which is also what the `condition_id` hash is computed over — you must price/sign the legs in this same order. See §6 for the full canonicalization rule, the `condition_id`/position-id derivation, and the complete conflict-check taxonomy the relay runs before this message is ever sent.

**`escrow_terms` is the thing to price against, not `requested_size`.** The server enforces (§5) that your signed `takerCollateral` equals the *gross* `taker_collateral_e6`, but your economic edge should be computed against `net_stake_e6` (gross minus the certain, deposit-time protocol + builder fee). Mixing the two under/overprices your quote (see the worked identity in §7).

**`spread_bps` is informational, not something you sign.** It is the *win-only*, taker-side markdown the escrow owner has configured for this RFQ — it is applied by the contract when the taker *wins*, on top of whatever pot your quote implies. It is **not** a field of the v2 `ParlayQuote` struct (§11) and the server does not check anything you send against it. This is the headline difference from the v1 `ParlayQuote`, which *did* include a maker-signed `spreadBps` — that field simply does not exist in v2.

### `ACK_RFQ_QUOTE`

```ts
{ type: "ACK_RFQ_QUOTE"; rfq_id: string; quote_id: string }
```

Sent after your `RFQ_QUOTE` passes every validation in §5 and is accepted into the RFQ's live quote book. `quote_id` is **server-generated** — a WS quote never gets to choose its own id (contrast the REST path, §12, where the client may supply one). Getting an ACK means your quote is live and competing; it does **not** mean you won.

### `ACK_RFQ_QUOTE_CANCEL`

```ts
{ type: "ACK_RFQ_QUOTE_CANCEL"; rfq_id: string; quote_id: string }
```

Reply to `RFQ_QUOTE_CANCEL` (§5). Sent even when there was nothing to withdraw — cancel is idempotent (§5, §7-style race handling).

### `RFQ_CONFIRMATION_REQUEST`

```ts
{
  type: "RFQ_CONFIRMATION_REQUEST",
  rfq_id: string, quote_id: string,
  signer_address: Address, maker_address: Address, signature_type: 0,
  leg_position_ids: string[], condition_id: Hex,
  yes_position_id: Hex, no_position_id: Hex,
  direction: "BUY", side: "YES",
  fill_size_e6: string,   // == your quote's size_e6 (the pot)
  price_e6: string,       // == your quote's price_e6
  confirm_by: number,     // unix ms — you MUST reply before this or you are auto-declined
}
```

Sent **only** to the winning maker, and **only** if that maker's session had `last_look: true` at auth time. If you never opt into last-look you will never receive this message — your accepted quote is executed unconditionally.

The window is `confirm_by = now + lastLookMs`, where `lastLookMs` defaults to **1000 ms** (`V2_LASTLOOK_MS` env). This is intentionally tight: last-look is meant for a maker doing a final risk check (e.g. a stale-price guard), not a human in the loop. If you don't reply in time (or the WS send itself fails to deliver, e.g. the socket just dropped), the server auto-fires a `DECLINE` on your behalf, and the RFQ falls back to the next-best live quote (§7) rather than dying — unless you were the *only* quote or `maxFallbacks` (default 2) is exhausted, in which case the RFQ terminates `REJECTED`.

### `ACK_RFQ_CONFIRMATION_RESPONSE`

```ts
{ type: "ACK_RFQ_CONFIRMATION_RESPONSE"; rfq_id: string; quote_id: string; decision: "CONFIRM"|"DECLINE" }
```

Confirms your `RFQ_CONFIRMATION_RESPONSE` was accepted (§5). Also sent when you `CONFIRM`/`DECLINE` via the REST endpoint (`POST /v1/maker/confirmations`) — in that case it still arrives over the WS socket if you have one open, since the server looks you up by `maker_address` in its session table regardless of which transport you replied on.

### `RFQ_EXECUTION_UPDATE`

```ts
{ type: "RFQ_EXECUTION_UPDATE"; rfq_id: string; status: "MATCHED"|"MINED"|"RETRYING"|"CONFIRMED"|"FAILED"; tx_hash?: Hex }
```

{% hint style="warning" %}
**The declared status union over-promises.** The type lists five statuses, but the relay only ever emits **three**: `MATCHED` (tx submitted, not yet mined), `CONFIRMED` (mined, the on-chain creation event decoded — includes an extra `parlay_id` field, see below), and `FAILED` (either the submission itself threw, or the receipt had no creation event). `MINED` and `RETRYING` are never sent by this relay. Do not write consumer code that blocks waiting for a status this server doesn't produce.
{% endhint %}

Only sent to the **winning** maker — losers never see it. On `CONFIRMED`, the actual payload also includes `parlay_id` (the on-chain parlay id, decimal string) even though the declared type doesn't list it — this is a v2-only extension over the field the type table promises; treat `parlay_id` as present-but-optional in your client types, exactly as the packaged SDK does.

As covered in §3, `MATCHED`/`CONFIRMED` are the immediate, actively-pushed execution confirmation — they are not the same event as settlement, which happens later and is not proactively pushed at all (only polled/queried, or watched on-chain).

### `RFQ_TRADE`

```ts
{
  type: "RFQ_TRADE", rfq_id: string, requester_id: string, condition_id: Hex,
  leg_position_ids: string[], direction: "BUY", side: "YES",
  price_e6: string, size_e6: string, executed_at: number,
}
```

Broadcast to **every currently-connected maker** (not just the winner) the moment a trade executes — this is the public tape, useful for competitive pricing intelligence even if you didn't win that particular RFQ.

### `RFQ_ERROR`

```ts
{ type: "RFQ_ERROR"; code: RfqErrorCode; error: string; request_type?: string; rfq_id?: string; quote_id?: string }
```

See §9 for the full code list and which ones a maker actually hits. `request_type` echoes back which of your messages triggered the error (e.g. `"RFQ_QUOTE"`, `"RFQ_QUOTE_CANCEL"`, `"RFQ_CONFIRMATION_RESPONSE"`, `"CASHOUT_OFFER"`) so you can correlate it against what you sent even without matching on `rfq_id`/`quote_id` alone.

### `ping` / `pong`

* **`{type:"ping", payload:"rfq"}`** — the periodic heartbeat from §1. Reply with `{type:"ping"}` (see below) if you want to be a good citizen, but you don't strictly need to — any authed message resets your liveness clock.
* **`{type:"pong"}`** — sent in reply to a maker-initiated `{type:"ping"}`.

### No `welcome`/backfill on connect

Unlike v1 (whose `hello`→`welcome` handshake backfills `openRfqs` so a freshly-connected maker can immediately quote in-flight RFQs), **v2 sends nothing on a successful `auth` beyond the `{type:"auth", success:true, ...}` response itself.** There is no snapshot of currently-open RFQs pushed to a newly authenticated maker. If you reconnect mid-flight, you will only see `RFQ_REQUEST`s broadcast **after** your `auth` succeeds — any RFQ that opened before you reconnected and is still in its quote window is invisible to you until the next new RFQ. Don't port v1's backfill-dependent logic (looping over an `openRfqs` array on the welcome message) into a v2 client — it will simply never fire.

***

## 5. Maker → server messages

### `RFQ_QUOTE`

```ts
{ type: "RFQ_QUOTE"; rfq_id: string; price_e6: string; size_e6: string; signed_order: SignedOrder }
```

`SignedOrder` (== the signed `ParlayQuote`):

```jsonc
{
  "maker": "0xYourMakerEOA",          // must == the AUTHED session's maker_address
  "taker": "0xTakerEOA",              // must == escrow_terms.taker from the RFQ_REQUEST
  "legsHash": "0xabc…",               // must == condition_id from the RFQ_REQUEST
  "takerCollateral": "25000000",      // must == escrow_terms.taker_collateral_e6 (GROSS, 6dp string)
  "makerCollateral": "1250000",       // your posted collateral (6dp string) — see §7 for how to compute
  "builder": "0x0000…0000",           // must == escrow_terms.builder
  "protocolFeeBps": 100,              // must == escrow_terms.protocol_fee_bps
  "builderFeeBps": 0,                 // must == escrow_terms.builder_fee_bps
  "nonce": "1234567890123456789012345678901234567890…",  // fresh random uint256 per quote
  "deadline": "1751234599",           // unix SECONDS, must be in the future
  "signature": "0x…"                  // EIP-712 sig over PARLAY_DOMAIN + ParlayQuote (§11)
}
```

The WS handler ignores any `maker_address`/`signer_address`/`signature_type` fields on the body itself and always uses the **authenticated session's** identity — you cannot spoof a different maker over WS even if you tried. (The REST path, §12, is the opposite: identity there comes from the body because there is no session — see the `ADDRESS_MISMATCH` check in §2 for why the WS path's session-vs-body comparison is effectively a no-op on this transport.)

**Full server-side validation** — get every one of these right or your quote is rejected before it ever competes:

| #  | Check                                                                                                                   | Error on failure                           |
| -- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| 1  | RFQ exists and is still `COLLECTING_QUOTES`                                                                             | `UNKNOWN_RFQ` / `SUBMISSION_WINDOW_CLOSED` |
| 2  | `signature_type === 0`                                                                                                  | `INVALID_IDENTITY`                         |
| 3  | `legsHash` (case-insensitive) `==` server-recomputed `condition_id` for these legs (§6)                                 | `QUOTE_MISMATCH`                           |
| 4  | `taker` `==` the RFQ's taker                                                                                            | `QUOTE_MISMATCH`                           |
| 5  | `builder` `==` the RFQ's effective builder                                                                              | `QUOTE_MISMATCH`                           |
| 6  | `protocolFeeBps` `==` the RFQ's regime                                                                                  | `QUOTE_MISMATCH`                           |
| 7  | `builderFeeBps` `==` the RFQ's regime                                                                                   | `QUOTE_MISMATCH`                           |
| 8  | `takerCollateral` (as bigint) `==` the RFQ's gross stake                                                                | `QUOTE_MISMATCH`                           |
| 9  | numeric fields (`takerCollateral`, `makerCollateral`, `nonce`, `deadline`) parse as bigints                             | `INVALID_QUOTE`                            |
| 10 | `makerCollateral > 0`                                                                                                   | `INVALID_QUOTE`                            |
| 11 | `deadline` is in the future (unix seconds `>= now`)                                                                     | `INVALID_QUOTE`                            |
| 12 | wire `price_e6` and `size_e6` are both `> 0`                                                                            | `INVALID_QUOTE`                            |
| 13 | wire `price_e6` within **±1** of the server's own recomputation (round-half-up: `(netStake*1e6 + size_e6/2) / size_e6`) | `QUOTE_MISMATCH`                           |
| 14 | wire `size_e6` **exactly equals** `netStake + makerCollateral`                                                          | `QUOTE_MISMATCH`                           |
| 15 | EIP-712 signature recovers to `maker` over `PARLAY_DOMAIN` + `ParlayQuote`                                              | `INVALID_QUOTE`                            |
| 16 | `maker` is in the server's approved-makers set (standing allowance ≥ 1e12, i.e. ≥ $1M)                                  | `ALLOWANCE_VALIDATION_FAILED`              |
| 17 | maker's live on-chain USDC balance `>= makerCollateral`                                                                 | `BALANCE_VALIDATION_FAILED`                |

Checks 13 and 14 encode a strict economic identity that is easy to get subtly wrong: `size_e6` (the pot/payout) is **not** a free-choice number you send to describe your quote — it is *defined* as `net_stake_e6 + makerCollateral`, and `price_e6` is *defined* as `round(net_stake_e6 * 1e6 / size_e6)` (i.e. the implied probability of the taker's combo, scaled to 1e6). You compute `makerCollateral` from your edge (§7), then derive `size_e6` and `price_e6` from it — never the other way around, and never independently.

**NOTE: spread is not checked here at all.** There is nothing in `SignedOrder` for spread to occupy, and the server does not compare anything you send against `escrow_terms.spread_bps`. This is intentional (§4).

### `RFQ_QUOTE_CANCEL`

```ts
{ type: "RFQ_QUOTE_CANCEL"; rfq_id: string; quote_id: string; signer_address: Address; maker_address: Address }
```

The server checks ownership against the **session's** `maker_address`, not the one in the message body. Semantics:

* **Idempotent.** Cancelling a quote that doesn't exist (already expired/never existed) returns `{withdrawn:false}` with no error — it's a no-op ACK, not a failure.
* **Ownership-checked.** If the quote exists but belongs to a different maker/signer, you get `ADDRESS_MISMATCH` — thrown as a real error, not silently ignored.
* **Locked quotes stay binding.** If your quote is the *current winner* and the RFQ has already moved past `COLLECTING_QUOTES` (i.e. it's `AWAITING_REQUESTER_ACCEPTANCE`, `AWAITING_MAKER_CONFIRMATION`, or `EXECUTING`), the cancel is ACKed but reports `{withdrawn:false}` — you already won, and cancelling now does not un-commit you. Cancel before the quote window closes if you genuinely need to pull liquidity.

### `RFQ_CONFIRMATION_RESPONSE`

```ts
{ type: "RFQ_CONFIRMATION_RESPONSE"; rfq_id: string; quote_id: string; decision: "CONFIRM"|"DECLINE" }
```

Only meaningful if you received a `RFQ_CONFIRMATION_REQUEST` (§4) for this `rfq_id`/`quote_id` — i.e. you opted into `last_look` and won. Important edge cases:

* **Only the winning maker may respond.** If a different maker (or the same maker on a stale `quote_id`) tries, you get `UNAUTHORIZED_ROLE`.
* **Wrong RFQ state.** If the RFQ isn't currently `AWAITING_MAKER_CONFIRMATION` — e.g. you reply after the auto-decline timeout already fired, or you reply to an RFQ that never had a last-look step because your winning quote had `last_look===false` — you get a specific `MAKER_NOT_REQUIRED` error in that latter case, or a generic `INVALID_CONFIRMATION` otherwise.
* **Double response.** Responding a second time (or after the timeout already resolved it) gets `MAKER_ALREADY_RESPONDED`.

### `ping`

`{type:"ping"}` → `{type:"pong"}`. Also resets your liveness clock, same as any other authed message (§1).

### Cashout messages

`CASHOUT_OFFER` and `CASHOUT_CANCEL` are covered in full in §10 — they share this same `/ws/rfq` socket and the same session/auth, but are a logically separate sub-protocol for a different product surface (secondary-market novation on an already-filled parlay, not a new RFQ auction).

### Unknown message type

Anything with a `type` the server doesn't recognize gets `{type:"RFQ_ERROR", code:"INVALID_MESSAGE", error:"unknown message type: <type>"}`.

***

## 6. Combo identity: condition\_id, position ids, and conflict checks

Every RFQ is built from an ordered list of legs, each a `{outcomeId, sideIndex}` pair (one side of one HIP-4 outcome). Before any RFQ is created — long before `RFQ_REQUEST` is ever broadcast to a maker — the relay runs a fixed identity + validation pipeline over the raw leg list a taker submitted. This section is the canonical reference for that pipeline; `condition_id` mentions elsewhere on this page (the `RFQ_REQUEST` shape in §4, the `QUOTE_MISMATCH` check #3 in §5) are all describing the same mechanism defined here.

### Canonicalization

Raw legs are canonicalized before anything else happens to them:

1. **Sort** by `outcomeId` ascending, then by `sideIndex` ascending as a tiebreak.
2. **De-duplicate** — an exact repeat of the same `{outcomeId, sideIndex}` pair is dropped silently (only the first occurrence survives sorting).

This canonical order is the *only* order used downstream: it's what gets hashed into `condition_id`, it's what's returned as `leg_position_ids` on the wire, and it's the order a maker must price and sign against. Because canonicalization happens once, server-side, before hashing, `condition_id` can never diverge from what the escrow contract computes for the same leg set at settlement time — a maker (or a from-scratch reimplementation) that independently canonicalizes and hashes an identical raw leg list will always land on the same value the relay did.

### `condition_id` derivation

`condition_id` **is** the on-chain `legsHash` — they are the same value, just two names for it (`legsHash` is the field name inside the signed `ParlayQuote`/on-chain struct; `condition_id` is the name it's given on the RFQ wire). It is computed as:

```
perLegHash[i] = keccak256(abi.encode(LEG_TYPEHASH, legs[i].outcomeId, legs[i].sideIndex))
condition_id  = keccak256(abi.encodePacked(perLegHash[0], perLegHash[1], ..., perLegHash[n-1]))
```

over the canonical (sorted, deduped) leg array, where `LEG_TYPEHASH` is the EIP-712 type hash for `ParlayLeg(uint32 outcomeId,uint8 sideIndex)` (§11 has the full type table and byte-level detail for a from-scratch implementation).

From `condition_id`, two further identifiers are derived — one for each side of the combo as a binary outcome:

```
yes_position_id = keccak256(abi.encodePacked(condition_id, "YES"))
no_position_id  = keccak256(abi.encodePacked(condition_id, "NO"))
```

These are packed-string hashes (not EIP-712 struct hashes) — a `bytes32` concatenated with a literal ASCII `"YES"`/`"NO"`, then keccak'd. All three values (`condition_id`, `yes_position_id`, `no_position_id`) are included verbatim on every `RFQ_REQUEST` and `RFQ_CONFIRMATION_REQUEST` (§4) and on every `RFQ_TRADE` tape entry (§4), so a maker never has to compute them itself to consume the feed — but it must recompute `condition_id` independently to validate what it's about to sign (see the `QUOTE_MISMATCH` check below).

### Conflict checks, in order

Before an RFQ is ever created, the raw leg list is run through the following checks, in this exact order, on the **canonical** (post-sort/dedup) leg array. The first failing check throws and the RFQ is rejected outright — no maker ever sees it, and the taker gets the error directly on their `POST /v1/rfq` (or taker-WS equivalent) call, never via `RFQ_ERROR` on the maker channel.

| #  | Check                                           | Failure condition                                                                                     | Error code                 |
| -- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------- |
| 1  | Structural: leg list non-empty and array-shaped | missing/empty/non-array `legs`                                                                        | `INVALID_RFQ`              |
| 2  | Structural: leg count within bounds             | fewer than `minLegs` (2) or more than `maxLegs` (6) legs after dedup                                  | `INVALID_RFQ`              |
| 3  | Contradictory legs: same outcome, both sides    | the same `outcomeId` appears with both `sideIndex` values in the combo                                | `CONTRADICTORY_LEGS`       |
| 4  | Leg metadata / liveness                         | an `outcomeId` doesn't resolve to a known, live market                                                | `LEG_METADATA_UNAVAILABLE` |
| 4b | Leg already finalized                           | the outcome resolves to metadata but the market has already settled/finalized                         | `INVALID_RFQ`              |
| 5  | Same-market mutual exclusion                    | two or more legs resolve to the same underlying market/question id                                    | `CONTRADICTORY_LEGS`       |
| 6  | Same-entity exclusion                           | two or more legs share the same named entity (e.g. the same team appearing on both sides of a parlay) | `CONTRADICTORY_LEGS`       |
| 7  | Category homogeneity                            | the combo's legs span more than one category (e.g. mixing a crypto-price leg with a sports leg)       | `CONTRADICTORY_LEGS`       |

Notes on this table:

* **Checks 3–7 run in this exact order**, and each one only runs once the checks before it have passed (e.g. same-market/entity/category checks only see legs that already have resolved metadata from check 4).
* **Check 4b intentionally reuses `INVALID_RFQ`, not a dedicated code**, distinguishing "we don't recognize this outcome at all" (`LEG_METADATA_UNAVAILABLE`) from "we recognize it, but it's already over" (`INVALID_RFQ`) — both are fatal to the combo, but a client can use the code to tell a truly-unknown outcome id apart from a stale/expired one it fetched from a cache.
* **Legs without a resolvable category are exempt from check 7** — they're allowed alongside any other category, so only a genuine mix of two or more *known, differing* categories trips it.
* All of `CONTRADICTORY_LEGS`, `LEG_METADATA_UNAVAILABLE`, and the `INVALID_RFQ` variants above are **taker-side/creation-time errors** — as noted in §9, a maker's error handler will not normally see these on the maker channel, because by construction a maker is never shown an RFQ that failed this pipeline. They're documented here (and cross-referenced from §9) for completeness and because a maker running its own pre-flight simulation of a taker's leg list (e.g. to build competitive market-making tooling) needs the exact taxonomy to reproduce the relay's behavior.

### Consistency with the rest of this page

The `RFQ_REQUEST` shape in §4 and the quote-validation check #3 in §5 (`legsHash` must equal the server-recomputed `condition_id` for the RFQ's legs) both refer to exactly the mechanism described above — there is only one `condition_id`/`legsHash` computation in the system, used identically for RFQ identity, quote validation, and the on-chain `legsHash` the escrow checks at settlement.

***

## 7. Winner selection (how to win RFQs)

`pickBest` filters out canceled/expired quotes and any maker that has already declined-via-last-look for this RFQ, then sorts the remainder by:

1. **Lowest `price_e6` first.**
2. Tiebreak: **earliest `received_at`** (server-assigned receive timestamp).

Since `price_e6 = round(net_stake_e6 * 1e6 / size_e6)` and `size_e6` is the pot (what the taker receives on a win), a *lower* `price_e6` for the same `net_stake_e6` means a *larger* pot — i.e. lowest price directly corresponds to the best (highest) payout for the taker. Practically: **quote a smaller edge and you quote a lower `price_e6` and you win more often.** There's no separate "spread" lever for you to tune here — your only control is how tight a `makerCollateral` you're willing to post for a given combined probability.

The worked pricing identity (exactly what the packaged SDK's pricing helper implements):

```
combinedProb   = Π over legs of (sideIndex==0 ? p_leg : 1 - p_leg)     // your own leg pricing, clamped to [0.0001, 0.98]
edgeFactor     = 1 - max(0, edgeBps + jitterBps) / 10_000               // your house margin, optionally jittered
payout         = net_stake_e6 * edgeFactor / combinedProb               // the pot you're willing to imply
makerCollateral = max(0.01, payout - net_stake_e6)                      // your posted risk capital
size_e6        = net_stake_e6 + makerCollateral                        // == payout, by construction
price_e6       = round(net_stake_e6 * 1e6 / size_e6)                    // what you send on the wire
```

Note `payout` and `net_stake_e6` here are the *net*-stake-denominated numbers (post deposit-fee); the `takerCollateral` you sign into `SignedOrder` is still the **gross** `escrow_terms.taker_collateral_e6` — the escrow does the fee split on-chain. Mixing gross and net in this formula is the single most common way to misprice a v2 quote.

***

## 8. Standing allowance (EIP-2612)

Rather than approve USDC per-trade, a maker signs **one** large, long-lived EIP-2612 `Permit` and hands it to the relay in the `auth.approval` field (§2). The relay's relayer key submits it gaslessly on the maker's behalf:

* If the maker already has an on-chain allowance `>= 1e12` (1,000,000 USDC at 6dp) to the escrow, the relay just marks it approved locally and skips the on-chain call entirely.
* Otherwise it calls the USDC token's own `permit(maker, escrow, value, deadline, v, r, s)` function and only marks the maker approved once that transaction is mined.
* **This is a background, best-effort step** — auth still succeeds immediately regardless of whether the permit submission has landed yet (fired-and-not-awaited). If your quote arrives before the permit transaction confirms, it will fail `ALLOWANCE_VALIDATION_FAILED` even though you *did* send an approval at auth time — build in a short grace period or explicit confirmation wait before quoting aggressively right after a fresh connect on a brand-new maker key.
* A maker that omits `auth.approval` entirely is fine **if** it already has a qualifying on-chain allowance from a prior session or a manual `approve`/`permit` call — the check in §5 (the approved-makers set) only cares about the relay's local approved-set, which the periodic on-chain check above populates either way.

**The `Permit` struct** (EIP-2612, standard shape):

```ts
{ owner: Address; spender: Address; value: uint256; nonce: uint256; deadline: uint256 }
```

signed against `PERMIT_DOMAIN = { name: "USD Coin", version: "1", chainId, verifyingContract: USDC }`. The wire shape sent in `auth.approval` is the split-signature form:

```ts
{ value: string; deadline: string; v: number; r: Hex; s: Hex }
```

`owner` (== your maker address) and `spender` (== the escrow) are implied by context and not part of the wire payload — the server reconstructs the full `Permit` message itself before calling `permit()`. A convention followed by the reference maker bots (and by the packaged SDK's standing-approval signer): `value = 2^200` (an effectively-infinite standing allowance) and `deadline = now + 365 days`, so you only ever have to do this once per key, in practice.

**Split-signature note.** `v` must be the canonical `27`/`28` form. viem's `parseSignature` returns either `v` (already `27n`/`28n`) or a `yParity` (`0`/`1`) depending on the signature; the correct normalization used throughout this codebase is:

```ts
v = v != null ? Number(v) : 27 + (yParity ?? 0)
```

***

## 9. Error model

The full `RfqErrorCode` union has **28 values**; each one maps to an HTTP status for the REST surface (§12) — over WS, every error is instead delivered as `{type:"RFQ_ERROR", code, error, ...}` regardless of what the "HTTP status" would have been.

**Codes a maker actually encounters** (with the section that explains each):

| Code                          | Where                                                   | Meaning                                                                                                               |
| ----------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHENTICATED`             | any message before `auth` succeeds                      | send `auth` first (§2)                                                                                                |
| `INVALID_IDENTITY`            | `auth`, `RFQ_QUOTE`                                     | non-EOA `signature_type`, or a malformed/missing address                                                              |
| `ADDRESS_MISMATCH`            | `RFQ_QUOTE` (REST only in practice), `RFQ_QUOTE_CANCEL` | identity doesn't match the session/quote owner                                                                        |
| `UNKNOWN_RFQ`                 | `RFQ_QUOTE`, `RFQ_QUOTE_CANCEL`, confirmation           | no such `rfq_id`, or its context expired                                                                              |
| `SUBMISSION_WINDOW_CLOSED`    | `RFQ_QUOTE`                                             | you quoted after `COLLECTING_QUOTES` ended (§3/§5 check 1)                                                            |
| `QUOTE_MISMATCH`              | `RFQ_QUOTE`                                             | any of checks 3–8, 13, 14 in §5's table failed                                                                        |
| `INVALID_QUOTE`               | `RFQ_QUOTE`                                             | checks 9–12, 15 failed (malformed numerics, non-positive collateral, expired deadline, bad signature)                 |
| `ALLOWANCE_VALIDATION_FAILED` | `RFQ_QUOTE`                                             | no qualifying standing allowance yet (§8)                                                                             |
| `BALANCE_VALIDATION_FAILED`   | `RFQ_QUOTE`                                             | your live USDC balance can't cover `makerCollateral`                                                                  |
| `INVALID_MESSAGE`             | any                                                     | unparseable JSON, or unrecognized `type`                                                                              |
| `INVALID_CONFIRMATION`        | `RFQ_CONFIRMATION_RESPONSE`                             | wrong RFQ state, or a malformed `decision`                                                                            |
| `MAKER_NOT_REQUIRED`          | `RFQ_CONFIRMATION_RESPONSE`                             | you're replying to a winning quote that had `last_look:false` — there was never a confirmation step to respond to     |
| `MAKER_ALREADY_RESPONDED`     | `RFQ_CONFIRMATION_RESPONSE`                             | you (or the timeout) already resolved this confirmation                                                               |
| `UNAUTHORIZED_ROLE`           | `RFQ_CONFIRMATION_RESPONSE`                             | you are not the winning maker for this RFQ                                                                            |
| `REQUEST_FAILED`              | any handler                                             | a non-typed exception was thrown server-side (a bug, or an unexpected input shape) — treat as retriable/report-worthy |

Codes that exist in the type union but are effectively taker-side or engine-internal — `CONTRADICTORY_LEGS`, `EXPIRED_RFQ`, `INVALID_ACCEPTANCE`, `INVALID_EXECUTION_RESULT`, `INVALID_RFQ`, `INVALID_RFQ_STATE`, `INVALID_ROLE`, `LEG_METADATA_UNAVAILABLE`, `PRE_EXECUTION_BALANCE_RESERVATION_FAILED`, `QUOTE_UNAVAILABLE`, `RATE_LIMITED`, `SERVICE_UNAVAILABLE`, `TRADE_SUBMISSION_FAILED`, `UNKNOWN_RFQ` (in its creation-time sense) — can still theoretically arrive at a maker via the `RFQ_ERROR` envelope. `CONTRADICTORY_LEGS` and `LEG_METADATA_UNAVAILABLE` specifically are the combo-conflict codes from §6's taxonomy: they fire during RFQ *creation*, before any `RFQ_REQUEST` is broadcast, so in normal operation a maker never sees them (the taker gets them directly on their create call) — they are enumerated here only because the type system doesn't distinguish "maker-reachable" from "creation-time-only" codes. One exception worth calling out explicitly: `TRADE_SUBMISSION_FAILED` **is** sent to the **winning maker** if the on-chain parlay-creation transaction itself reverts after you already confirmed — so a maker client's error handler should treat `code` as an open `string` for display/logging purposes even though the enumerated set above is what you'll see under normal operation.

`RFQ_ERROR`'s optional `request_type`/`rfq_id`/`quote_id` fields are populated on a best-effort basis by whichever internal error-construction call produced the error — use them to correlate, but don't assume they're always all present (e.g. a top-level `INVALID_MESSAGE` for unparseable JSON has none of them).

***

## 10. Cashout (novation) maker sub-protocol

Cashout lets a maker buy out an **already-executing, partly-resolved** parlay's taker side early: the current taker gets paid now, and the maker becomes the new taker on-chain (`novate()`). This is a standing-offer book, not an RFQ auction — there is no competing "quote window"; instead, one live offer per maker per position, superseded by a fresher signed offer from the same maker.

### `CASHOUT_OFFER` (maker → server)

```ts
{
  type: "CASHOUT_OFFER";
  parlay_id: string;          // on-chain parlay id, decimal
  payout_e6: string;          // USDC (6dp) you're offering to pay the CURRENT taker
  resolvedWonCount: number;   // the resolution state you priced + signed against — see "pinning" below
  voidedCount: number;
  nonce: string;              // your own uint256 nonce, fresh per offer
  deadline: string;           // unix seconds
  signature: Hex;             // EIP-712 sig over PARLAY_DOMAIN + CashoutOffer
  valid_until?: number;       // unix ms book-TTL; defaults to deadline*1000 if omitted
}
```

Handled in three steps:

1. The server reads the **live on-chain position state** for `parlay_id` — if the parlay doesn't exist on-chain (`taker == address(0)`), you get `UNKNOWN_POSITION`.
2. It reconstructs the **exact** struct you must have signed, with the on-chain `taker` substituted in server-authoritatively (you cannot lie about who the current taker is — only the maker identity and pricing terms are yours to choose) and recovers the signature. A mismatch → `INVALID_OFFER`.
3. You must already have a qualifying standing allowance (§8) — same approved-makers set as the RFQ path — else `INVALID_OFFER`.

**Reply:** `{type:"ACK_CASHOUT_OFFER", parlay_id, offer_id}` on success, or `{type:"CASHOUT_ERROR", code, error, parlay_id}` on failure — note this is a **different** envelope type from `RFQ_ERROR`.

### `CASHOUT_CANCEL` (maker → server)

```ts
{ type: "CASHOUT_CANCEL"; parlay_id: string; offer_id: string }
```

→ `{type:"ACK_CASHOUT_CANCEL", parlay_id, offer_id, withdrawn: boolean}`. Same idempotent-cancel and ownership-check semantics as `RFQ_QUOTE_CANCEL` (§5); `withdrawn:false` for an already-locked offer (accepted, executing on-chain) or a nonexistent one.

### Supersede & pinning semantics

* **One live offer per maker per position.** A fresh, successfully-ingested offer from the same maker on the same `parlay_id` silently replaces its predecessor.
* **An offer pins the resolution state it was priced against.** `resolvedWonCount` and `voidedCount` are exactly the values you observed when you signed. The moment the *actual* on-chain state moves — any leg resolves — every live offer whose pinned counts no longer match gets dropped from the book, *and* the on-chain `novate()` enforces the identical pin independently, so a stale offer can never be force-accepted even in a race. A taker attempt to accept a since-invalidated offer surfaces `STALE_OFFER` to whoever queries it; you should treat any resolution movement as "re-price and resend a fresh offer with a new `nonce`" — this is exactly the loop a reference maker bot's evaluate/tick cycle implements.
* **Position lifecycle.** `POSITION_SETTLED`/`POSITION_DEAD` mean the parlay has fully resolved (won/lost) — there is nothing left to cash out; stop tracking it. `MAKER_IS_TAKER` means you *are* the current taker on this position — cashing yourself out makes no sense and is rejected.
* **Locked offers stay binding through cancel**, mirroring `RFQ_QUOTE_CANCEL`: once a taker acceptance is executing, a `CASHOUT_CANCEL` ACKs with `withdrawn:false` rather than yanking liquidity mid-settlement.

### `CashoutOffer` EIP-712 struct

See §11 for the full type table; summarized here for convenience:

```
CashoutOffer(address maker, address taker, uint256 parlayId, uint256 payout,
             uint8 resolvedWonCount, uint8 voidedCount, uint256 nonce, uint256 deadline)
```

### Cashout error codes

`CashoutErrorCode`: `UNKNOWN_POSITION`, `POSITION_SETTLED`, `POSITION_DEAD`, `MAKER_IS_TAKER`, `INVALID_OFFER`, `STALE_OFFER`, `OFFER_EXPIRED`, `OFFER_UNAVAILABLE`, `UNKNOWN_OFFER`, `ADDRESS_MISMATCH`. These are a **separate enum** from `RfqErrorCode` (§9) and always arrive wrapped as `{type:"CASHOUT_ERROR", code, ...}`, never as `RFQ_ERROR`.

### Where your offer goes (taker-side, for context)

A maker's job ends at posting/cancelling offers. The taker-facing accept flow — `GET /v1/positions/:parlayId/cashout`, `POST .../cashout/prepare`, `POST .../cashout/accept` — is out of scope for a maker integration, but understanding it helps: `prepare` returns a `CashoutAcceptance` struct for the taker to sign; `accept` relays the taker's signature and your offer's signature together into `novate()`, which pre-flights your **live** balance and allowance one more time immediately before submission — so an offer you posted an hour ago can still fail at accept-time if you've since spent down your USDC. Keep your standing allowance funded for as long as you have live offers out.

Cashout is a narrow, position-scoped exception to the general no-settlement-push rule from §3/§4: the relay does periodically push `cashout:offer` updates to takers who are actively subscribed to a specific position's cashout book, so they can see live buyout pricing move. This is not a general settlement notification — it only exists for parlays that have at least one live cashout offer, and it says nothing about final resolution/payout.

***

## 11. EIP-712 domains & type tables (appendix)

All of the following are reproduced **verbatim** from the relay's pure hashing module, which is the unit-tested source both the server and the packaged SDK build from.

### Domains

```ts
export const PARLAY_DOMAIN = {
  name: "ParlayEscrow", version: "1",
  chainId: CHAIN_ID,           // 998 = HyperEVM testnet, by default (V2_CHAIN_ID env)
  verifyingContract: ESCROW,   // 0x551D4A7EdB71583C7C12900128727D857e308925 by default (V2_ESCROW env)
} as const;

export const PERMIT_DOMAIN = {
  name: "USD Coin", version: "1",
  chainId: CHAIN_ID,
  verifyingContract: MOCK_USDC,  // 0x75b69d0E8fA3CdB644b0f069E4e876B85A76E137 by default (V2_USDC env)
} as const;
```

{% hint style="danger" %}
**Never hardcode `chainId`/`verifyingContract` in a maker client.** The v2 escrow (`0x551D4A7E…`) is a **different deployment** from the v1 demo escrow (`0xBCB080…`) that still serves the legacy v1 relay — and the v2 escrow address has rotated multiple times as features landed (novation, gasless `withdrawWithSig`). A quote signed against the wrong domain simply won't recover to your address and gets `INVALID_QUOTE`. Pin these values to whatever the relay you're actually connecting to reports as its live deployment (the taker channel's bootstrap/ready message carries this — a maker client should be configured with the same values out-of-band, or read them from the same bootstrap source your integration uses for the taker side). This is the same discipline the taker SDK already follows — see its EIP-712 module's "never hardcode `verifyingContract`" comment.
{% endhint %}

### Type tables

```ts
// ParlayQuote — the maker-signed order. NO spreadBps (v2 dropped it; see §4).
const QUOTE_T = [
  { name: "maker", type: "address" },
  { name: "taker", type: "address" },
  { name: "legsHash", type: "bytes32" },
  { name: "takerCollateral", type: "uint256" },
  { name: "makerCollateral", type: "uint256" },
  { name: "builder", type: "address" },
  { name: "protocolFeeBps", type: "uint16" },
  { name: "builderFeeBps", type: "uint16" },
  { name: "nonce", type: "uint256" },
  { name: "deadline", type: "uint256" },
] as const;   // 10 fields — a v1 ParlayQuote has 11 (adds spreadBps between builderFeeBps and nonce)

// ParlayOrder — the TAKER's countersignature (a maker never signs this; documented for context).
const ORDER_T = [
  { name: "taker", type: "address" },
  { name: "quoteHash", type: "bytes32" },
  { name: "nonce", type: "uint256" },
  { name: "deadline", type: "uint256" },
] as const;

// ParlayLeg — hashed per-leg, then packed-keccak'd into legsHash/condition_id (§6).
const LEG_T = [
  { name: "outcomeId", type: "uint32" },
  { name: "sideIndex", type: "uint8" },
] as const;

// EIP-2612 Permit — standard shape, for the standing allowance (§8).
const PERMIT_T = [
  { name: "value", type: "uint256" },
  { name: "deadline", type: "uint256" },
  { name: "v", type: "uint8" },
  { name: "r", type: "bytes32" },
  { name: "s", type: "bytes32" },
] as const;
// (the actual EIP-712 Permit signed is {owner,spender,value,nonce,deadline} — PERMIT_T above
//  is the wire SHAPE of the split signature the server expects in auth.approval, not the
//  EIP-712 struct itself; see PERMIT_TYPES below for the struct that's actually signed.)
const PERMIT_TYPES = {
  Permit: [
    { name: "owner", type: "address" },
    { name: "spender", type: "address" },
    { name: "value", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
} as const;

// CashoutOffer — the maker-signed novation offer (§10). Field order MUST match
// the on-chain EIP-712 type definitions byte-for-byte.
const CASHOUT_OFFER_T = [
  { name: "maker", type: "address" },
  { name: "taker", type: "address" },
  { name: "parlayId", type: "uint256" },
  { name: "payout", type: "uint256" },
  { name: "resolvedWonCount", type: "uint8" },
  { name: "voidedCount", type: "uint8" },
  { name: "nonce", type: "uint256" },
  { name: "deadline", type: "uint256" },
] as const;

// CashoutAcceptance — the TAKER's countersignature accepting one specific cashout offer
// (a maker never signs this; documented for context, §10's "where your offer goes").
const CASHOUT_ACCEPTANCE_T = [
  { name: "taker", type: "address" },
  { name: "offerHash", type: "bytes32" },
  { name: "nonce", type: "uint256" },
  { name: "deadline", type: "uint256" },
] as const;
```

### Type hashes

```
LEG_TYPEHASH           = keccak256("ParlayLeg(uint32 outcomeId,uint8 sideIndex)")
QUOTE_TYPEHASH         = keccak256("ParlayQuote(address maker,address taker,bytes32 legsHash,uint256 takerCollateral,uint256 makerCollateral,address builder,uint16 protocolFeeBps,uint16 builderFeeBps,uint256 nonce,uint256 deadline)")
CASHOUT_OFFER_TYPEHASH = keccak256("CashoutOffer(address maker,address taker,uint256 parlayId,uint256 payout,uint8 resolvedWonCount,uint8 voidedCount,uint256 nonce,uint256 deadline)")
```

### Hash algorithms (for a non-viem / non-JS integrator to reproduce byte-for-byte)

**`legsHashOf(legs)`** — `legsHash == condition_id` (§6). Hash each leg individually with the leg typehash, then keccak the tight-packed concatenation of those per-leg hashes, **in the canonical sorted leg order**:

```
perLegHash[i] = keccak256(abi.encode(LEG_TYPEHASH, legs[i].outcomeId, legs[i].sideIndex))
legsHash      = keccak256(abi.encodePacked(perLegHash[0], perLegHash[1], ..., perLegHash[n-1]))
```

**`quoteHashOf(quote)`** — the EIP-712 struct hash of `ParlayQuote` (this is what `ParlayOrder.quoteHash` binds to — the taker's countersignature commits to exactly this value):

```
quoteHash = keccak256(abi.encode(
  QUOTE_TYPEHASH, maker, taker, legsHash, takerCollateral, makerCollateral,
  builder, protocolFeeBps, builderFeeBps, nonce, deadline
))
```

**`cashoutOfferHashOf(offer)`** — the EIP-712 struct hash of `CashoutOffer` (this is what a `CashoutAcceptance.offerHash` binds to):

```
offerHash = keccak256(abi.encode(
  CASHOUT_OFFER_TYPEHASH, maker, taker, parlayId, payout,
  resolvedWonCount, voidedCount, nonce, deadline
))
```

All three are then wrapped in the standard EIP-712 envelope (`keccak256("\x19\x01" || domainSeparator || structHash)`) by whatever signer you use — a viem `signTypedData`/`recoverTypedDataAddress` call handles this automatically given the domain + types + message above; a from-scratch implementation must replicate the envelope manually.

***

## 12. REST equivalents (brief)

The maker channel is WS-first (§1) — a `POST` request has no way to receive a broadcast `RFQ_REQUEST`, so a pure-REST maker would have to poll, which this relay doesn't expose an endpoint for. The REST surface exists as a fallback for makers that already have a quote ready (e.g. driven by an external event) and don't want to hold a persistent socket open purely to submit it:

| Method + path                  | Purpose                                                                                                                                               |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/maker/quotes`        | Submit a quote (body includes explicit `maker_address`/`signer_address`/`signature_type`/`quote_id` since there's no session) — same validation as §5 |
| `POST /v1/maker/quotes/cancel` | Cancel a quote                                                                                                                                        |
| `POST /v1/maker/confirmations` | Respond to a last-look confirmation                                                                                                                   |

All three funnel into the **exact same engine methods** as the WS handlers — there is no separate REST-only validation path, and errors come back as the standard `{error, code}` JSON body with the HTTP status mapped from the error code (§9) instead of a WS envelope. There is no REST equivalent for `CASHOUT_OFFER`/`CASHOUT_CANCEL` (those are WS-only, maker-authenticated-session-only) or for receiving `RFQ_REQUEST` itself (WS-only, by necessity).

***

**See also:** [`docs/parlay-v2/cashout.md`](/mute.sh/integration-guides/cashout.md) for the taker-facing cashout narrative, [`docs/parlay-v2/README.md`](/mute.sh/readme.md) for the system overview and contract addresses, and the `@mutesh/parlay-maker` package README for a ready-to-use TypeScript client built to this exact spec.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://mute-sh.gitbook.io/mute.sh/reference/maker-protocol-spec.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
