Skip to main content
WSS
This user-scoped stream uses (feedEpoch, globalUserSeq) and has no snapshot. Without seq, it starts at the oldest retained trade involving the user. With seq, replay starts strictly after that cursor before joining live delivery.

Subscribe

Decoded event

The shape below is the inner message returned by decodeExchangeWsEnvelope or decodeExchangeWsMessage. Every binary socket message also carries an outer id: bigint, matching your subscription ID. Use it to route snapshots, replay, and live updates. See binary subscription envelopes.
users is ordered [takerId, makerId]. An active FIFO fill carries the resting maker’s user ID and is delivered to both nonzero users. A passive fill has no individual maker and uses 0n as the maker ID. Active fills have distinct taker and maker IDs; contracts prevent self fills. takerSide always describes the taker side, so the active maker’s side is the opposite value. The stable identity of a fill is (marketAddress, tradeId, recordIndex). For an explicit replay cut R, accept the first publication when globalUserSeq > R and previousGlobalUserSeq is null or at most R. After that sparse bridge, every subsequent previousGlobalUserSeq must equal the last accepted globalUserSeq exactly.

Fees, source identity, and match boundaries

Fee rates are historical event values in PPS (denominator 10_000_000). liquidity.makerFeePps applies only to active makers; passive liquidity omits that field. Prices and sizes retain market price/size precision. The client calculates fees and positions; the Gateway exposes the inputs only. sourceBlock, txHash, txIdx, logIdx, and recordIndex identify the source record. A packed log becomes separate normalized records: the current projector emits one trade per publication, rather than packing the complete match into one trades array. Iterate rows in delivery order. matchEnd: true closes a match. A match can span multiple packed logs with intervening book/hook records; one log can also contain multiple matches. Takers receive all their active and passive fills. A maker receives only its own fills and may never receive the match’s terminal record. Maker buys use the contract’s per-record two-stage quote floor. For active maker sells, exact quote credit is a cumulative active-ask ceiling delta and can depend on preceding fills belonging to other makers. A maker-only stream therefore cannot always reconstruct exact quote credit or fee-adjusted positions from its own rows. Use complete ordered match data or authoritative indexed accounting for those amounts. For example, a taker 101 matching maker 202, passive liquidity, then maker 303 receives three publications. Their rows have users values [101n, 202n], [101n, 0n], and [101n, 303n], with matchEnd values false, false, and true. Maker 202 receives only the first row; maker 303 receives only the third. Persist unfinished match state together with the replay cursor. If a reconnect loses that state, replay from before the unfinished match; handle a history gap by bootstrapping again. The existing epoch and predecessor rules still apply. See the response changelog for every added field and its binary offset.

Canonical event timestamp

Each decoded trade row or order delta event includes blockTimestamp: bigint in Unix seconds. It is the source block’s canonical header timestamp and is identical in live delivery and retained replay. It is not the publication time. Open-order snapshot rows use their retained creation timestamp, createdAt. See the event timestamp changelog.

Contract quote arithmetic

For taker sells, quote uses two floors: floor(floor(price * size / sp) * quoteScale / pp). For passive taker buys, both stages round up. For active taker buys, maintain W = sum(price * size) across only active fills in the match, and charge ceil(W_after * quoteScale / (sp * pp)) - ceil(W_before * quoteScale / (sp * pp)). Passive fills do not change W; reset it at matchEnd. Buy builder fees are ceil(sum(size) * baseSizeMultiplier * builderFeePps / 10_000_000) in raw base atoms. Keep raw-atom precision when updating positions; do not first round the fee to size precision. The Gateway exposes inputs and performs none of this accounting. operation is optional outcome metadata, not another fill or an instruction to mutate an order twice.

Calculating client-side PnL

The following is the Indexer’s weighted-average, long-inventory accounting model. PnL is per user and market, denominated in that market’s quote token; it is not automatically a USDC value. Use arbitrary-precision integer arithmetic (for example, JavaScript bigint), never floating-point numbers for accounting.

1. Start from a position baseline

Read the user trades API, including data.positions, feedEpoch, and globalUserSeq. Subscribe strictly after that cursor using the replay rules above. Do not reapply the historical trades in the API response to its already-materialized position baseline. For each market, keep:
  • B: open inventory in raw base-token atoms.
  • C: open cost in raw quote-token atoms.
  • R: cumulative realized PnL in raw quote-token atoms (signed).
  • Market pp (price precision), sp (size precision), and both token decimals.
Let baseScale = 10^baseDecimals, quoteScale = 10^quoteDecimals, and baseSizeMultiplier = baseScale / sp. API openSize is in size precision, so convert its exact decimal string to raw atoms by multiplying by baseSizeMultiplier; do not truncate fractional size units. API openCost already uses raw quote atoms. Convert x18 realized PnL using the quote token’s decimals. Do not rebuild cost from a rounded display average entry price. data.positions supplies size and cost, not cumulative realized PnL. To display lifetime R, retain a verified realized-PnL baseline or reconstruct it from complete indexed history at the same cutoff; one page of trade history is not sufficient. Alternatively initialize R = 0 and label the result as realized PnL since the baseline, not lifetime PnL. For quote tokens with more than 18 decimals, an x18 value can lose raw-atom precision; it cannot substitute for exact raw state when reproducing integer accounting. The baseline and replay cursor must describe the same indexed state. Preserve the epoch, accepted cursor, position state, and any unfinished match together. After an epoch change or unavailable replay history, reload the baseline.

2. Calculate each fill’s quote and the complete match’s fees

Use the contract quote arithmetic above to calculate q[i], the raw quote amount of fill i, and b[i] = baseFilled[i] * baseSizeMultiplier. Preserve delivery order and carry the active-ask accumulator across logs until matchEnd; neither a WebSocket frame nor a log boundary closes a match. With D = 10_000_000 (PPS, parts per scale):
Maker protocol fees are per-fill quote fees. Taker protocol fees and builder fees are computed once per complete match, not independently rounded per fill. Use the historical rates carried by the event, not current fee-tier defaults. Passive fills have no individual maker position to update. builderFeePps is the builder fee rate, not an amount. It is separate from effectiveTakerFeePps: apply both fees to the taker, but do not charge the builder fee to the active maker. A zero builder rate produces a zero builder fee. The buy-side builder fee is in raw base atoms; the sell-side builder fee is in raw quote atoms. Do not add amounts in these different units. To reproduce the Indexer’s individual trade rows, allocate the taker protocol fee and sell-side builder fee using q[i] as weights; allocate the buy-side builder fee using b[i] as weights. For each nonterminal fill assign floor(totalFee * weight[i] / sum(weights)); the final fill receives the exact remaining fee. If both the fee and total weight are zero, all allocations are zero. Apply the resulting legs in order, then publish the taker’s position at matchEnd. This preserves both fee totals and per-fill cost-basis rounding.

3. Apply fee-adjusted buys and sells

For a buy, let b be the acquired raw base amount and q its raw quote amount. A taker receives b - allocatedBuilderBaseFee; an active maker receives b. Add the corresponding quote protocol fee to cost:
Buy fees are incorporated into inventory/cost, not immediately booked as realized PnL. In particular, a base-token builder fee reduces inventory while the quote cost remains invested. For example, a single-fill taker buy of 2 tokens for 200 USDC with effectiveTakerFeePps = 2_000 and builderFeePps = 10_000 incurs a 0.04 USDC protocol fee and a 0.002-token builder fee (assuming the token decimals can represent these amounts exactly). From an empty position, store 1.998 tokens of inventory and 200.04 USDC of cost. Average entry becomes 200.04 / 1.998 USDC per token, rather than 200.04 / 2. At a 100 USDC mark, unrealized PnL is 1.998 * 100 - 200.04 = -0.24 USDC; realized PnL is still zero. Do not also subtract the base builder fee’s quote value from realized PnL: its effect is already reflected in the reduced inventory. For a sell, let b be the sold raw base amount and f the quote fees: maker protocol fee for a maker, or allocated taker protocol plus builder quote fees for a taker. Using the position before this fill:
When B = 0, released cost and both matched quote amounts are zero. When the position closes, set B' = C' = 0. A sale beyond tracked inventory does not create a short position: only the matched portion contributes realized PnL. Do not subtract sell fees a second time after using matchedNetQuote. For a single-fill taker sell of 1 token for 110 USDC, the same rates give a 0.022 USDC protocol fee and a 0.11 USDC builder fee. Net proceeds are 110 - 0.022 - 0.11 = 109.868 USDC. If the released cost is 100 USDC, realized PnL is 9.868 USDC. The Indexer’s reported net PnL already includes these fees; clients displaying that value must not deduct them again.

4. Derive average entry and marked PnL

Use zero when B = 0. At a mark price markPriceX18 in quote tokens per base token, the unrealized value is the marked open inventory minus open cost:
Convert signed realized PnL to x18 with integer truncation toward zero, matching the Indexer. These are quote-token values; a non-USDC quote needs its own USDC conversion before account-wide aggregation. Mark-price changes affect unrealized PnL even when this user has no new trades. For example, with no builder fee, buying 2 tokens for 200 USDC plus a 0.04 USDC protocol fee creates 2 tokens of inventory at 200.04 USDC cost. Selling 1 token for 110 USDC with a 0.022 USDC fee releases 100.02 USDC cost and realizes 110 - 0.022 - 100.02 = 9.958 USDC. The remaining cost is 100.02 USDC. At a 110 USDC mark, unrealized PnL is 9.98 USDC and total PnL is 19.938 USDC.

Transfers are also position changes

Trades alone are insufficient to maintain the Indexer’s position state. Deposits, withdrawals, and internal account transfers also adjust inventory and basis in the token’s designated first market, without changing realized PnL. An incoming transfer uses the recipient’s existing average cost; when there is no open position it uses the latest preceding trade price, or zero if no price exists. An outgoing transfer removes proportional cost, resetting inventory/cost to zero if it exhausts or exceeds the position. Internal transfers apply each account’s own basis; they do not transfer the sender’s cost basis to the recipient. Balance updates do not identify these accounting operations. Unless your client also consumes complete, ordered transfer history, refresh authoritative indexed positions rather than treating a balance delta as another trade.
userTrades@f subscription
type:object