Skip to main content

0015. Combo Accept: the server signs the stored quote and sends it once

  • Status: Proposed
  • Date: 2026-10-01

Context

Accept is the one Combos step that trades. The venue’s quote carries the amounts to sign (maker_amount_e6, taker_amount_e6) and the total the trade costs (total_required_e6, fee included); the fee sits outside the signed order. A quote lives 5 to 10 s. Polymarket’s docs and the SDK say a repeated Accept “can be safely retried”, and the SDK retries once after a transport error. CLAUDE.md forbids an automatic retry of a non-idempotent venue request: if the first request reached the venue, a second one relies on the venue’s deduplication, which the app cannot check. The venue has a status read, GET /requests/{rfq_id}, that answers 409 when no Accept was recorded. ADR 0011 set the same rules for placements.

Decision

  1. The browser sends ids only: client_order_id (a new UUIDv7), rfq_id and quote_id. It sends no amount, price, token or leg.
  2. engine/orders holds this app’s quote requests of the last 10 minutes in memory. It looks up the RFQ and checks that quote_id is its quote, locks the quote to this Accept, and reserves its amount against max_combo_exposure (or its shares against the held position for a sell). A second Accept of the same quote is refused combo_already_accepted; a quote at or past its expiry is refused combo_quote_expired.
  3. engine/orders writes the intent row, engine/risk CheckCombo decides (Armed, every trading gate, the combos scope, max_orders_per_minute, the legs, and for a buy max_combo_notional, max_combo_exposure and max_daily_notional against total_required_e6; for a sell the held shares), and the decision row is written.
  4. The adapter checks the stored quote’s amounts against the request (signing.CheckQuoteAmounts) and signs an Exchange V3 order with those amounts copied unchanged. The signed row records the order hash and the SHA-256 of the exact body. Nothing is sent if that row cannot be written.
  5. The Accept is sent once. A refusal the venue gives before taking it (any 4xx other than 408 and 409 INVALID_RFQ_STATE) is final. A timeout, a lost or unreadable answer, a 408, any 5xx (503 included) and 409 INVALID_RFQ_STATE are unknown.
  6. An executing or unknown Accept is settled only by status reads of its rfq_id: every 500 ms, then every 5 s once it has run for a minute, with backoff after a failed read. CONFIRMED or FILLED with a tx_hash is filled; FAILED, CANCELED, EXPIRED, REJECTED or an unknown status is not filled; a 409 means the Accept was not recorded and settles it as rejected with venue_not_found. Each result is a reconcile row. The Accept is never re-sent. This overrides the venue docs’ “safely retry”.
  7. Resumed at restart. At start, every Accept with a signed row and no final response or reconcile row is queued for status reads with its reservation held.
A quote request is never repeated either: a lost answer is no_quote with combo_no_response, and nothing can be accepted.

Consequences

  • A compromised or buggy browser cannot change what is signed: the amounts come from the venue’s quote as the server stored it, checked against the request.
  • A double click or a replayed request sends at most one Accept.
  • A crash after the signed row is settled at the next start by the RFQ’s status.
  • An unknown Accept can take seconds or minutes to settle; the slip shows Checking… and the quote cannot be accepted again meanwhile.
  • If the venue’s deduplication would have made a retry safe, the app gives up that retry: an Accept lost in transit ends as venue_not_found, and the user asks for a new quote.
  • Open: security finding P8-01. As built, the first 409 settles the Accept, though Polymarket may record it a moment later. The proposed fix settles “not recorded” only when 409 answers continue past the quote’s expires_at plus a margin. It changes step 6, not the rule that the Accept is never re-sent.

Alternatives considered

  • Retry once, as the SDK does. Rejected: it trusts a deduplication the app cannot check, and CLAUDE.md forbids it.
  • Let the browser send the signed amounts or the quote. Rejected: the server would sign what the browser says, and risk would check numbers the venue never quoted.
  • Settle from the CLOB user feed or the Data API. Rejected: neither is documented for requester trades (venue notes §16, A15), and the RFQ status is the single source of quote request state (PLAN §3.7).