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
- The browser sends ids only:
client_order_id(a new UUIDv7),rfq_idandquote_id. It sends no amount, price, token or leg. engine/ordersholds this app’s quote requests of the last 10 minutes in memory. It looks up the RFQ and checks thatquote_idis its quote, locks the quote to this Accept, and reserves its amount againstmax_combo_exposure(or its shares against the held position for a sell). A second Accept of the same quote is refusedcombo_already_accepted; a quote at or past its expiry is refusedcombo_quote_expired.engine/orderswrites the intent row,engine/riskCheckCombodecides (Armed, every trading gate, the combos scope,max_orders_per_minute, the legs, and for a buymax_combo_notional,max_combo_exposureandmax_daily_notionalagainsttotal_required_e6; for a sell the held shares), and the decision row is written.- 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. - 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 409INVALID_RFQ_STATEare unknown. - 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.CONFIRMEDorFILLEDwith atx_hashis filled;FAILED,CANCELED,EXPIRED,REJECTEDor an unknown status is not filled; a 409 means the Accept was not recorded and settles it as rejected withvenue_not_found. Each result is a reconcile row. The Accept is never re-sent. This overrides the venue docs’ “safely retry”. - 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.
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_atplus 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.mdforbids 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).