Messages
{
"op": "sub",
"id": 8,
"t": "userTrades@f",
"u": 2,
"feedEpoch": 0,
"seq": 2951
}Exchange WebSocket methods
User trades stream
Replay and follow a user’s finalized taker- and active-maker-side executions.
WSS
/
ws
userTrades@f
(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
{"op":"sub","id":8,"t":"userTrades@f","u":2,"feedEpoch":0,"seq":2951}
Decoded event
The shape below is the innermessage 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.
type ExchangeWsUserTradeLiquidity =
| {
kind: "activeFifo";
slotIndex: number;
orderId: bigint;
makerSide: "buy" | "sell";
remainingBaseAfter: bigint;
makerFeePps: number;
}
| {
kind: "passiveBand";
lowPrice: bigint;
passiveSideRemainingAfter: bigint;
};
interface ExchangeWsUserTradesFrame {
wireVersion: 1;
feedEpoch: bigint;
view: "finalized";
kind: "userTrades";
userId: bigint;
globalUserSeq: bigint | null;
globalSeq: bigint | null;
previousGlobalUserSeq: bigint | null;
sourceBlock: { blockNumber: bigint; blockId: string };
trades: Array<{
marketAddress: string;
tradeId: bigint;
recordIndex: number;
users: [bigint, bigint];
takerSide: "buy" | "sell";
price: bigint;
baseFilled: bigint;
blockTimestamp: bigint;
liquidity: ExchangeWsUserTradeLiquidity;
txHash: string;
txIdx: number;
logIdx: number;
effectiveTakerFeePps: number;
builderFeePps: number;
matchEnd: boolean;
action: { accountId: bigint; executor: string; clientOrderId: string | null };
operation: { outcome: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; operationIndex: number; replacementSlot: number } | null;
}>;
}
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 (denominator10_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 includesblockTimestamp: 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, JavaScriptbigint), never floating-point numbers for accounting.
1. Start from a position baseline
Read the user trades API, includingdata.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.
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 calculateq[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):
Q = sum(q[i])
S = sum(b[i])
takerProtocolFee = ceil(Q * effectiveTakerFeePps / D)
taker buy: builderBaseFee = ceil(S * builderFeePps / D)
taker sell: builderQuoteFee = ceil(Q * builderFeePps / D)
active maker fee for fill i = floor(q[i] * makerFeePps[i] / D)
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, letb 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:
B' = B + netBaseReceived
C' = C + q + protocolQuoteFee
R' = R
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:
m = min(b, B)
releasedCost = C if m = B
floor(C * m / B) otherwise
matchedGrossQuote = floor(q * m / b)
matchedNetQuote = floor((q - f) * m / b)
grossRealizedPnl = matchedGrossQuote - releasedCost
realizedPnl = matchedNetQuote - releasedCost
B' = B - m
C' = C - releasedCost
R' = R + realizedPnl
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
averageEntryPriceX18 = floor(C * baseScale * 10^18 / (B * quoteScale))
B = 0. At a mark price markPriceX18 in quote tokens per base
token, the unrealized value is the marked open inventory minus open cost:
markedInventoryX18 = floor(B * markPriceX18 / baseScale)
openCostX18 = floor(C * 10^18 / quoteScale)
unrealizedPnlX18 = markedInventoryX18 - openCostX18
totalPnlX18 = realizedPnlX18 + unrealizedPnlX18
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.Messages
{
"op": "sub",
"id": 8,
"t": "userTrades@f",
"u": 2,
"feedEpoch": 0,
"seq": 2951
}userTrades@f subscription
type:object