fix(perps-controller): order 0: Reduce only order would increase position - #10037
Conversation
The entry cited #10029, a number guessed before the PR existed. CI's changelog check requires the entry to link the PR that introduces the change, which is #10037, and failed with "There are changes made to this package that may not be reflected in the changelog". changelog:validate only checks formatting, so this passed locally while the CI check did not.
The entry cited #10029, a number guessed before the PR existed. CI's changelog check requires the entry to link the PR that introduces the change, which is #10037, and failed with "There are changes made to this package that may not be reflected in the changelog". changelog:validate only checks formatting, so this passed locally while the CI check did not.
077c060 to
abd8d51
Compare
The entry cited #10029, a number guessed before the PR existed. CI's changelog check requires the entry to link the PR that introduces the change, which is #10037, and failed with "There are changes made to this package that may not be reflected in the changelog". changelog:validate only checks formatting, so this passed locally while the CI check did not.
3a4afeb to
8a63b04
Compare
There was a problem hiding this comment.
The reduce-only sizing and position freshness work is well tested, and the current CI suite passes. I am requesting changes for two exit-path regressions that affect production behavior:
- Closing selected positions now depends on loading position data for every enabled market group.
- A delayed position update from a replaced WebSocket connection can be accepted as current after reconnect.
Please scope selected position closes to the market groups represented by the requested symbols, and reject callbacks from replaced subscription clients. The batch level error documentation should also be aligned with the result shapes returned at runtime.
I found no additional TypeScript or numeric precision concerns, and this PR does not change React code.
HyperLiquid rejects a reduce-only order whose size the live position cannot absorb with "Order 0: Reduce only order would increase position". reduce-only path and kept both defects. Sizes were rounded half-up by formatHyperLiquidSize's toFixed, so a position on a half-increment was submitted larger than it is (0.115 at szDecimals 2 becomes 0.12). The existing Math.min clamp ran before that rounding and was undone by it, and validation compared the raw value while the exchange received the formatted one. Floor onto the asset's size grid inside formatPartialTpslSize, which covers every partial TP/SL caller, and in the whole-position fullSize expression. A size that floors to zero still raises ORDER_TPSL_SIZE_INVALID rather than being sent as '0', which the exchange reads as covering the whole position. Also re-validate the caller's position snapshot against the WebSocket position cache before building the order, as closePosition does. Clients pass a throttled snapshot, so a concurrent fill leaves its size too large and a flip leaves the side wrong. The cache read issues no REST request. A symbol the cache does not hold, or an uninitialised cache, keeps the caller's snapshot rather than failing the update.
Flooring reduce-only TP/SL sizes widens the band refused with ORDER_TPSL_SIZE_INVALID from below half a size increment to below a whole one: a size in between (0.0005 at szDecimals 3) previously rounded up to one increment and reached the exchange, and is now refused. That is the intended trade, but it is consumer-visible and the changelog read as unchanged behaviour carried over from #9674. Spell the widened band out in its own changelog bullet, naming one full increment as the smallest accepted size, and note that partial flooring covers the TP/SL attached to placeOrder as well as updatePositionTPSL. Pin the boundary with four regressions: 0.0004, 0.0005 and 0.0009 at szDecimals 3 are refused, and 0.001 is accepted. The 0.0005 and 0.0009 cases fail against the unfixed source, so the band is locked rather than incidental; 0.0004 passes either way and pins the half that did not change.
The entry cited #10029, a number guessed before the PR existed. CI's changelog check requires the entry to link the PR that introduces the change, which is #10037, and failed with "There are changes made to this package that may not be reflected in the changelog". changelog:validate only checks formatting, so this passed locally while the CI check did not.
Follows up #9719 and the TP/SL fix on this branch, which left three sibling paths able to submit a reduce-only order the exchange rejects with "Order 0: Reduce only order would increase position", plus the close half of TAT-3873. - closePositions built its batch from the aggregate WebSocket cache, which is only rebuilt once every expected DEX has published and can sit frozen at its pre-reconnect contents. Each position is now refreshed against its own DEX's slice, so a stale size or side no longer reaches the exchange. - closePosition with no caller snapshot looked the position up in that same frozen aggregate and reported "No position found" for a position that is open (TAT-3873, close half). It now consults the per-DEX slices first, and still fails closed when a published slice agrees the position is gone. - editOrder formatted a reduce-only size with half-up rounding and no floor, so an edit on a half-increment was submitted above the requested ceiling. Reduce-only sizes are floored; one below a full increment is refused with ORDER_SIZE_POSITIVE. Non-reduce-only edits keep their existing rounding. Per-DEX slices are only trustworthy for the connection that produced them, so HyperLiquidClientService now tracks a connection epoch, incremented on every raw socket close. That is the only signal covering an SDK-internal automatic reconnect: the SDK reopens the socket without notifying callers, and terminate fires only once retries are exhausted. Each slice is stamped with the epoch of the clearinghouseState payload that produced it and is rejected unless the stamp still matches, so any reconnect invalidates it automatically while a retry on the same connection preserves it. updateMargin shares the same aggregate read and is deliberately left alone: it is not a reduce-only order path and belongs in its own change.
Three gaps found in the final branch review. validateOrderPrecision gates updatePositionTPSL before trading setup prompts for signatures and writes builder-fee/referral approvals, and before the pre-cancel sweep clears the position's existing triggers. It checked sizes with the half-up formatter while submission floors them, so a size in [0.5, 1) size increments (0.0007 at szDecimals 3) passed the gate and was refused only after those side effects had run — with the sweep having already left the position unprotected. The gate now floors, so it agrees with the size that is submitted. Snapshot revalidation in updatePositionTPSL and closePosition was gated on isPositionsCacheInitialized(), which is only set once every expected DEX has published. During a staggered start or reconnect it reads false while the symbol's own slice is already live, so the guard could only suppress a fresher answer. The per-DEX getter owns freshness and returns null unless its slice was published on the current connection. The single REST fallback below it stays gated: a cold cache means the WebSocket has delivered nothing, and the snapshot shortcut exists so a close in that state costs no request. The connection-epoch close listener is bound to the transport it was attached to. Teardown is asynchronous, so a replaced transport can still emit close, and acting on it would retire the current connection's epoch and discard live positions for a healthy connection. Late events from a retired transport are ignored, and each transport-discard site retires its own epoch explicitly, so manual and SDK-internal reconnects remain covered without depending on an event that may never arrive.
The clearinghouseState callback read the connection epoch at delivery time. A payload that arrived on the retired socket can be delivered after a reconnect has already advanced the epoch, so it was stamped with the new one — marking pre-reconnect positions as live on the current connection. That is worse than carrying no stamp at all: the slice reads as authoritative, so a reduce-only close on that DEX builds from a size the exchange has already moved past. Capture the epoch when each clearinghouse subscription is created, after the subscription client is ensured so it reflects the connection the subscription attaches to, and stamp deliveries with that captured value. A message queued on a retired connection therefore carries its own retired epoch and is rejected on read, while a delivery on the live subscription still stamps as current. The reconnect regression simulated a reconnect by advancing the epoch while reusing the pre-reconnect subscription's callback to publish the "post-reconnect" payload, which that callback correctly no longer stamps as current. It now resubscribes first, as a real reconnect does, and still fails when the epoch comparison is removed.
…ion epoch" This reverts commit 7a7121b.
Scope selected closePositions to the requested symbols' market groups, ignore delayed clearinghouse payloads after a subscription-client replacement, and document ClosePositionsResult.error as a batch-level operation error.
d4e527e to
d30dfbe
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d30dfbe. Configure here.
Isolate selected closePositions DEX loads so a PROVIDER_NOT_AVAILABLE failure on one requested market group does not abort closes on the others.

Explanation
Fixes the remaining TAT-3252 failure paths. A stale position or half-up size rounding could submit a reduce-only order larger than the current position. #9719 fixed single-position close only.
Core now uses one position-source rule:
This covers TP/SL, single and batch close, reduce-only edits, margin updates, and HIP-3 margin sizing. No public API changes.
Proof
d4e527eb85:updateMarginsucceeded, a 100% partial TP rested at no more than the position size, the position stayed open, and cleanup closed it.References
Checklist
Note
High Risk
Changes core trading paths (close, batch close, TP/SL, margin) and WebSocket position cache semantics; mistakes could submit wrong reduce-only sizes or skip live positions.
Overview
Hardens HyperLiquid reduce-only flows (close, batch close, TP/SL, margin updates, HIP-3 margin sizing) so order size and side always come from one fresh source, not caller snapshots or a frozen aggregate cache.
Position freshness: WebSocket per-DEX slices are stamped with a connection epoch that advances on socket close/reconnect; stale
clearinghouseStatefrom a replaced client is ignored. Symbol-scoped actions use the current DEX slice or a single HTTP read;closeAll/empty symbols require a complete all-DEX WebSocket snapshot or a strict REST fan-out (PROVIDER_NOT_AVAILABLEif any DEX is missing). REST and WebSocket positions are never mixed.closePositionsloads only DEXes for the requested symbols and can return per-symbol unavailable results while still closing others.Sizing: Partial TP/SL, whole-position TP/SL formatting, and reduce-only
editOrdersizes are floored to the venue grid (with earlyORDER_TPSL_SIZE_INVALID/ORDER_SIZE_POSITIVE); non-reduce edits keep half-up rounding.API/docs: Optional batch-level
erroronClosePositionsResult; optionalpositionon close/TP/SL is documented as diagnostic only.Reviewed by Cursor Bugbot for commit 2ab8e68. Bugbot is set up for automated code reviews on this repo. Configure here.