Skip to content

fix(perps-controller): order 0: Reduce only order would increase position - #10037

Merged
abretonc7s merged 26 commits into
mainfrom
TAT-3252-fix-fix-reduce-only-order-validati
Sep 2, 2026
Merged

fix(perps-controller): order 0: Reduce only order would increase position#10037
abretonc7s merged 26 commits into
mainfrom
TAT-3252-fix-fix-reduce-only-order-validati

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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:

  • symbol action: current DEX slice, otherwise HTTP;
  • batch action: complete current WebSocket snapshot, otherwise complete REST snapshot;
  • never merge REST and WebSocket positions.

This covers TP/SL, single and batch close, reduce-only edits, margin updates, and HIP-3 margin sizing. No public API changes.

Proof

  • Reproduced first: on pre-fix source, regressions submit an oversized TP/SL size and read stale position data, reproducing the Jira failure path.
  • Proved after: the same regressions pass. A live HyperLiquid testnet recipe passed 47/47 nodes on d4e527eb85: updateMargin succeeded, a 100% partial TP rested at no more than the position size, the position stayed open, and cleanup closed it.
  • The full Perps suite, TypeScript, ESLint, formatting, and changelog checks pass.

References

Checklist

  • Tests, JSDoc, and changelog updated
  • Breaking changes introduced

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 clearinghouseState from 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_AVAILABLE if any DEX is missing). REST and WebSocket positions are never mixed. closePositions loads 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 editOrder sizes are floored to the venue grid (with early ORDER_TPSL_SIZE_INVALID / ORDER_SIZE_POSITIVE); non-reduce edits keep half-up rounding.

API/docs: Optional batch-level error on ClosePositionsResult; optional position on 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.

@abretonc7s abretonc7s changed the title chore: prepare farmslot publication pkg-614b751d-mti08lcv fix: order 0: Reduce only order would increase position Sep 1, 2026
@abretonc7s
abretonc7s marked this pull request as ready for review September 1, 2026 01:46
@abretonc7s
abretonc7s requested review from a team as code owners September 1, 2026 01:46
@abretonc7s
abretonc7s deployed to default-branch September 1, 2026 01:46 — with GitHub Actions Active
@cursor
cursor Bot requested review from geositta and michalconsensys September 1, 2026 01:53
abretonc7s added a commit that referenced this pull request Sep 1, 2026
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.
abretonc7s added a commit that referenced this pull request Sep 1, 2026
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.
@abretonc7s
abretonc7s force-pushed the TAT-3252-fix-fix-reduce-only-order-validati branch from 077c060 to abd8d51 Compare September 1, 2026 09:56
Comment thread packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts Outdated
abretonc7s added a commit that referenced this pull request Sep 1, 2026
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.
@abretonc7s
abretonc7s force-pushed the TAT-3252-fix-fix-reduce-only-order-validati branch from 3a4afeb to 8a63b04 Compare September 1, 2026 11:56
@abretonc7s
abretonc7s enabled auto-merge September 1, 2026 12:14
@abretonc7s abretonc7s changed the title fix: order 0: Reduce only order would increase position fix(perps-controller): order 0: Reduce only order would increase position Sep 1, 2026
Comment thread packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts Outdated

@geositta geositta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Closing selected positions now depends on loading position data for every enabled market group.
  2. 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.

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts Outdated
Comment thread packages/perps-controller/src/types/index.ts Outdated
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.
@abretonc7s
abretonc7s force-pushed the TAT-3252-fix-fix-reduce-only-order-validati branch from d4e527e to d30dfbe Compare September 2, 2026 00:37

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts Outdated
@cursor
cursor Bot requested a review from Mrtenz September 2, 2026 00:41
Isolate selected closePositions DEX loads so a PROVIDER_NOT_AVAILABLE
failure on one requested market group does not abort closes on the others.
@abretonc7s
abretonc7s added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit e282e2f Sep 2, 2026
47 checks passed
@abretonc7s
abretonc7s deleted the TAT-3252-fix-fix-reduce-only-order-validati branch September 2, 2026 02:32
@abretonc7s abretonc7s mentioned this pull request Sep 2, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants