feat(perps): [perps-controller] Add Chase, TWAP, and Scale order types - #9832
Merged
Conversation
Adds three strategy placements to OrderType, each of which expands one request into an execution schedule rather than a single resting order. TWAP is native on HyperLiquid and goes through the venue's own TWAP action and cancel endpoint, not the order book. Scale fans out scaleNumOrders limit orders on an inclusive price ladder as a single batch, splitting the size in whole units of the asset's size grid so the rungs sum to exactly the submitted size. Chase has no native action anywhere in the SDK, so it is emulated: a post-only order rests at the near touch and is cancelled and re-placed as the touch moves, bounded by a poll interval, a window, and a repricing cap, and stopped by cancelOrder and by disconnect. CancelOrderParams gains an optional orderType that selects the cancellation path; omitting it keeps today's single-order behaviour, so no existing caller changes. OrderResult gains childOrderIds so a caller that has lost the session-scoped handle can still cancel the children. Invalid strategy params are rejected with typed error codes before any network call. TriggerOrderType was Exclude<OrderType, 'market' | 'limit'>, which would have absorbed the three new members and started demanding a trigger price from them; it is now spelled out. The resolved type is unchanged. The params model stays provider-agnostic: no venue vocabulary reaches OrderParams, OrderResult, or CancelOrderParams, and the one venue constant is named HYPERLIQUID_TWAP_LIMITS alongside the existing HYPERLIQUID_ORDER_LIMITS.
Classify scale and chase as limit execution. getTriggerExecution read LIMIT_EXECUTION_ORDER_TYPES, which means "carries OrderParams.price", so the two strategies that rest limit orders without carrying that field were treated as market execution: quoted at the taker rate and held to the tighter market-order cap. A new LIMIT_RESTING_ORDER_TYPES answers the execution question instead; twap stays market because its suborders cross the book. isLimitExecutionOrderType is left alone, since widening it would make validation demand a price strategy placements reject. calculateFees also quotes chase at the maker rate regardless of isMaker, because a post-only order can only ever fill as a maker. Report a partly rested scale ladder honestly. An 'na' grouping evaluates each entry independently, so the batch is not atomic and the JSDoc claiming otherwise was wrong. submittedSize now sums only the slices whose rungs actually rested. Reject strategy notionals the venue will reject. The per-order minimum applies to each order the venue receives: every ladder rung must clear it, and a TWAP is bounded by the venue's documented $100 minimum total instead. Both are now typed rejections rather than opaque exchange errors. Close the chase cancel/re-place race. A cancel arriving between the tick's cancel and its replacement left an order resting that the caller had no handle for; the tick now re-checks the session after cancelling. Move HYPERLIQUID_TWAP_LIMITS above the JSDoc block documenting HYPERLIQUID_ORDER_LIMITS, which it had been inserted into.
Recover a chase session whose tick threw. The catch handler logged and returned, leaving the session active with no pending timer. The damaging case was a throw from #restChaseOrder after the cancel had succeeded: nothing on the book, session.orderId still holding the cancelled ID, and every later cancelOrder reporting ORDER_STRATEGY_CANCEL_INCOMPLETE against a dead order, so the handle could never be released. orderId is now cleared before the replacement is attempted rather than assigned from its result, and #recoverFailedChaseTick uses that to tell the two cases apart: an order still resting means a transient failure, so the chase is rescheduled until its deadline; nothing resting means the chase is over, so the session ends and its handle becomes releasable. Bound a scale ladder by its cheapest rung. Dividing the notional by the rung count prices every rung at spot, but the rungs rest across scaleMinPrice..scaleMaxPrice, so a buy ladder below the market could pass with every rung under the venue minimum and fail later as an untyped exchange rejection. The bound is now the rung at scaleMinPrice, sized from the same notional the per-order minimum was checked against. Mark the PerpsErrorCode union widening BREAKING in the changelog, with all sixteen new codes and the two client translation maps that must migrate. The previous entry was unmarked and listed only thirteen.
Stop a chase cancel from leaking the replacement it races. Clearing session.orderId before the replacement made the session honest about owning nothing, but it also made "replacement in flight" look exactly like "nothing rests": a cancelOrder landing during that round trip took the null-orderId path, deleted the session and returned success, while the tick went on to rest the replacement on a session that no longer existed - an order live on the exchange, a caller told the chase was cancelled, and an id that had never been returned in childOrderIds. The in-flight placement is now observable. ChaseSession.pendingReplacement is published before the placement starts, not derived from the promise #restChaseOrder returns, because the window opens the moment that method is entered and its synchronous prefix would otherwise be unguarded. #cancelChaseOrder awaits the marker before deciding what to cancel, so it acts on whatever actually rested, and the tick re-checks that its session is still registered and active once the placement lands. Correct the new error-codes changelog entry, which said thirteen parameter rejections where sixteen codes minus the four named non-rejections is twelve.
Reject strategy placements in editOrder. The edit path rejected trigger types but nothing else, so the widened OrderType let twap, scale and chase through as plain FrontendMarket modifications, quietly dropping the schedule, the ladder or the chase loop the caller asked for. A strategy placement is not a single resting order, so there is nothing for modify to rewrite: it now returns ORDER_EDIT_STRATEGY_UNSUPPORTED. Classify a rejected cancel instead of assuming the order still rests. HyperLiquid answers a cancel it cannot match with "Order was never placed, already canceled, or filled", which rejects the request but confirms what the caller wanted. Counting that as a failure meant one filled rung pinned a scale handle open forever, and a chase whose order filled returned ORDER_STRATEGY_CANCEL_INCOMPLETE on every retry with no way to release the handle. classifyCancelStatus sorts a status into cancelled / gone / refused; only refused leaves an order behind. That also corrects the re-pricing loop, which read any refused cancel as "the order is gone" and ended the session. A genuine refusal leaves the order resting, so placing the replacement would have doubled the position; the tick now leaves it alone and retries on the next tick.
Build and check the scale ladder before anything is signed. The rungs the venue receives were never the rungs anything was validated against: two distinct bounds could round onto one formatted price and stack the ladder, the notional check divided the total by the rung count while the grid split floors and puts the remainder on the first rung so no rung carries that average, and ORDER_SCALE_SIZE_TOO_SMALL came from inside the submit path, after trading readiness and the leverage update had already run. #buildScaleLadder now formats the prices and rejects duplicates, splits the sizes on the grid, and applies the venue minimum to the cheapest actual rung - all immediately after the asset info arrives, which is the earliest szDecimals is known and still before any side effect. #placeScaleOrder submits that ladder verbatim rather than recomputing it. Keep the rewards discount on chase replacements. TradingService sets the discount around placeOrder and clears it in a finally; a chase returns immediately, so every background replacement ran after the clear and was re-quoted at the undiscounted maximum. The fee is captured on the session at placement time and reused. Correct OrderResult.childOrderIds, which promised restart recovery it cannot give a chase: the ids stay valid for a scale ladder, but a chase replaces its child on every re-price and reports only the first. Export CHASE_ORDER_CONFIG, which the changelog advertised as public while the package root exported only its neighbours.
Size a chase replacement from what is still resting. Every re-price submitted the size the caller originally asked for, so a child that partially filled before it was cancelled was replaced at full size: a 1 ETH chase that filled 0.6 and then re-priced would execute 1.6. The remaining size is now read from open orders immediately before the cancel and carried on the session, and an order that has left the book entirely ends the session instead of re-placing anything. A fill landing between that read and the cancel is still unaccounted for; that window is one round trip, against the whole order size before. Check the TWAP and chase minimums against the size actually submitted. Sizing normally rounds up to meet the requested USD, but a reduce-only order may never round up - the venue rejects a close larger than the position - so it floors onto the size grid and a $100.10 reduce-only TWAP arrives as $99.90, under the venue minimum. Narrow ClosePositionParams.orderType to exclude the strategy types, which the type cannot carry fields for and closePosition cannot execute. Derived with Exclude on purpose: it shrinks as StrategyOrderType grows, so a strategy added later is refused automatically.
Close the chase over-execution window instead of narrowing it. The replacement size was sampled from open orders before the cancel, which left a round trip in which a fill could land and then be re-placed on top; that residue was recorded as unavoidable because the cancel response carries no fill data. The venue's order-status endpoint answers for an order that is no longer on the book, so the read moves after the cancel: once the cancel has landed no further fill can reach the order and the unfilled size it reports is final. An unknown oid or a zero remainder ends the session rather than re-placing. Same number of round trips, and the call ordering is what the regression test pins. Validate the rung count in splitScaleSizes. It is exported on its own, so it cannot rely on computeScalePriceLadder having vetted the count: zero returned an empty split and a fractional count returned slices that did not sum to the requested total. Add twap, scale and chase to PERPS_EVENT_VALUE.ORDER_TYPE. TradingService emits order_type verbatim, so every strategy placement was reaching analytics as a value the enum did not list - the same gap the trigger types were added to this enum to close.
Price a chase the way the venue defines it. HyperLiquid's docs say the order "rests one tick above the best bid (for buys) or one tick below the best ask (for sells), or at the best bid/ask when the spread is a single tick"; this rested at the touch instead, on the strength of a docs summary that had dropped the tick rule. computeChaseQuotePrice implements the real rule, with getPriceTick deriving the tick from whichever of the venue's two price bounds is coarser at that price. Net the chase's own order out of the book it reads. Resting inside the spread makes the chase its own best bid or ask, so the raw book shows its own quote as the touch: it would conclude nothing had moved and sit there while the market walked away. The level it rests on is netted down by its own size and skipped when nothing else is there. Guard the post-cancel remainder read. A cancel landing during that round trip found the old order already gone, reported success and dropped the handle, after which the tick rested a replacement nobody could reach. That was the fourth await in this loop needing the check; all four now have it. Enforce the venue's cap of five simultaneous chases, checked before the first order goes up and released when a chase is cancelled.
Clear the chase's order id when the cancel is confirmed, not after the remainder read. A read that rejected left the session naming an order already off the book, and recovery read that as "still resting" and rescheduled a chase with nothing live. Reserve a chase slot at the moment the cap is checked. Two round trips separate the check from the session registering, so concurrent placements could all pass it; an in-flight counter now closes that, with the placement body split into #startChaseSession so try/finally wraps every await. Net the chase's live resting size out of the book, not the size it was last placed for. A partially filled order occupies less of its level than it was placed for, so subtracting the recorded size could wipe out a level that still held external liquidity and quote against the level below. The live size is fetched only when the level is small enough for the difference to matter, which keeps the common tick to one book read. Narrow calculateOrderPriceAndSize and buildOrdersArray to the new OrdinaryOrderType. Both are exported and took the widened OrderType, so a consumer could hand them a strategy: chase would price as a limit order it carries no price for, and twap/scale would serialize as ordinary market orders.
Do not register a chase session whose provider was torn down while it was placing. The session registers only after the book read and the order, so a disconnect arriving between them had nothing to stop, and the placement then scheduled a background timer against a provider that had already stopped. A generation counter bumped by disconnect is captured before the round trips and checked before registering; the order it rested is still reported and left alone, as disconnect does with every resting order. Verify the chase's order when its own price stops showing on the book. A chase rests at or inside the touch, so a missing level is evidence the order has left the book - usually filled. Assuming otherwise meant that if the external touch computed to the same quote, no re-price was tried and the dead child held its session, and its slot against the venue's five-chase cap, until the window closed. Refuse clientOrderId on strategy placements. It is a public OrderParams field that no strategy submit path forwards, so callers got success against a correlation id the venue never saw. It cannot be honoured either: the TWAP action carries no client id, a scale ladder is many orders where the id must be unique per order, and a chase replaces its order on every re-price. readCancelledChaseRemainder becomes readOrderRemainder: it has answered for live orders since the netting fix, and the old name asserted something untrue at two of its three call sites.
Net every chase this provider runs on a side out of the book, not just the session asking. Each session netting only itself read the other as the external touch and improved a tick on it, and the other did the same back - walking each other across an unchanged market. Drop the scale branch from the notional preflight. It priced an average slice at the lowest rung, but the grid remainder lands on that rung, so the estimate could fall below every rung actually submitted and reject a ladder the venue would have taken. The check needs the asset's size grid, which validateOrder cannot see, and #buildScaleLadder already applies it exactly against the real slices before anything is signed. Keep the chase maker rate when the account has its own fee schedule. Both the cached and freshly fetched paths re-derived the maker/taker choice and quoted a post-only chase at the taker rate whenever isMaker was false, contradicting the base-rate path. Capture the teardown generation before the shared placement preamble rather than after it, so a disconnect during asset info, validation, trading readiness or the leverage update is seen by the registration check at the end.
Report an abandoned chase as a failure, not a success. A placement that outlived disconnect correctly registered no session, but still returned success with orderId set to the raw exchange id - where every other chase puts a session handle - so a caller cancelling by the documented route got ORDER_STRATEGY_HANDLE_UNKNOWN against a live order. It now resolves with ORDER_CHASE_ABANDONED and no orderId, carrying the resting order in childOrderIds where the ordinary single-order cancel can reach it. Check the chase concurrency cap before the shared preamble rather than after it. That preamble completes the signing setup and can change the asset's leverage, so a request destined to be refused for exceeding the venue's cap was paying for both first. Say what OrderResult.orderId actually carries: an exchange order id for an ordinary placement, a TWAP id or a client-generated group or session handle for a strategy one, with the individual exchange ids in childOrderIds.
Refuse an interrupted chase before it places anything. The teardown check sat after the book read and the order submission, so a disconnect during the multi-round-trip preamble still put a fresh ALO order on the book for a provider that no longer existed - it was only reported honestly, not prevented. The check now runs as soon as the preamble returns; the later check stays for the one window it cannot close, a disconnect arriving while the order is in flight, which is now the only case that reports a resting order in childOrderIds. Say what CancelOrderResult.orderId carries: the exchange order id for an ordinary cancel, the strategy handle when CancelOrderParams named one. Restate the changelog's rejection guarantee accurately. It claimed fourteen rejections before any network call, but the ladder's grid and notional checks need the asset's size precision and follow a metadata read, and the chase touch check follows the book read. The guarantee that holds is that every rejection is a typed code and nothing invalid is ever signed; the three exceptions are named, as are the two codes that describe what happened after a request rather than rejecting one.
abretonc7s
marked this pull request as ready for review
August 12, 2026 02:21
abretonc7s
temporarily deployed
to
default-branch
August 12, 2026 02:21 — with
GitHub Actions
Inactive
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 e0883cc. Configure here.
geositta
previously approved these changes
Aug 12, 2026
geositta
left a comment
Contributor
There was a problem hiding this comment.
The implementation cleanly separates TWAP, scale, and chase execution paths while preserving existing order behavior. Validation, cancellation semantics, and focused test coverage support the expanded API. I left one nonblocking comment about clarifying the pinned SDK's TWAP duration limit.
abretonc7s
enabled auto-merge
August 12, 2026 09:03
michalconsensys
approved these changes
Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Explanation
@metamask/perps-controllersupported six order types —market,limit, and the four triggerplacements — all of which resolve to a single order submitted through one code path. Chase, TWAP and
Scale are execution strategies: one request expands into a schedule of orders, and each needs a
different submission path, a different cancellation path, and rules of its own. None of that was
expressible.
This adds
twap,scaleandchasetoOrderType, with the parameters, validation, placement andcancellation each needs.
Protocol research (the ticket's open question). Answered from the SDK the package already depends
on,
@nktkas/hyperliquid@0.33.1: TWAP is native (twapOrder/twapCancel, with their ownendpoints); Scale is not — it is a batch of ordinary limit orders; Chase is not — no such
action exists anywhere in the SDK, so it is emulated client-side. The SDK schema also pins the TWAP
window to a whole number of minutes in
[5, 1440], stricter than the ticket's "twapDuration > 0", sovalidation enforces the real bound.
What each placement does.
venue's TWAP id. Cancelling it uses the TWAP cancel endpoint, never the order-book cancel.
scaleNumOrderslimit orders on an inclusive ladder betweenscaleMinPriceandscaleMaxPrice, submitted as one batch so the ladder rests together or fails together. Sizes aresplit in whole units of the asset's size grid, so the rungs sum to exactly the submitted size.
Children come back as
OrderResult.childOrderIds; the returned handle cancels all of them at once.background tick re-prices it as the touch moves, stopping at the repricing cap, at the window
deadline, when the order leaves the book, on cancel, or on
disconnect().Notable API effects.
OrderTypeis a wider union, which is breaking in the same way the triggertypes were in 11.0.0: consumer signatures that narrow it back to a smaller set must widen.
OrderParamsgains eight optional strategy fields,
OrderResultgainschildOrderIds, andCancelOrderParamsgains an optional
orderTypethat selects the cancellation path — omitting it, which every existingcaller does, keeps today's behaviour exactly. Invalid strategy parameters (inverted scale range,
out-of-range TWAP duration, a strategy field on a non-strategy order, a limit price on a strategy) are
rejected with a typed
PERPS_ERROR_CODESvalue before any network call.The public model stays provider-agnostic — no venue vocabulary reaches
OrderParams,OrderResultorCancelOrderParams. The one genuinely venue-specific constant is named for itsvenue,
HYPERLIQUID_TWAP_LIMITS, beside the existingHYPERLIQUID_ORDER_LIMITS.One incidental fix:
TriggerOrderTypewasExclude<OrderType, 'market' | 'limit'>, so the three newmembers would have been silently absorbed into the trigger union and started demanding a trigger
price. It is now spelled out. The resolved type is unchanged for existing consumers.
Validation. 108 new unit tests across three suites, driving the real provider against a stubbed
exchange client and asserting the exact actions submitted — that a TWAP reaches the TWAP action and
not the order action, that its cancel reaches the TWAP cancel endpoint and not the order cancel, the
exact scale ladder prices and the size split, the post-only chase placement at the touch and its
re-pricing loop, and that no exchange call is made for an invalid placement. The full package suite
passes with coverage thresholds met, the root build emits the new symbols, and the six pre-existing
order suites pass unmodified. A live read against HyperLiquid testnet confirms the controller still
instantiates and reads positions, orders and account state with the widened union in place.
Follow-ups, deliberately out of scope. Strategy-handle correlation is session-scoped (children
remain cancellable via
childOrderIdsafter a restart); TWAP progress is not surfaced in controllerstate, because the venue reports TWAPs through feeds the open-orders normalisation does not read; and
strategy placements are refused on sub-exchange (HIP-3) markets, whose pre-order margin transfer and
rollback are wired into the single-order submit path.
References
orderTypewhen cancelling a strategy handle. UI for these order types is explicitly out of scope here.
Checklist
OrderTypewidening is breaking in the same way as the trigger types in 11.0.0 and is markedBREAKING in the changelog, but no client draft PRs are prepared yet: the client work is a
separate ticket, and no consumer in this repo narrows
OrderType.Screenshots/Recordings
Note
High Risk
Widens the public
OrderTypeunion (breaking) and adds new signed trading paths, including a client-side chase loop with disconnect/cancel races and concurrency limits.Overview
Adds strategy order types
twap,scale, andchasetoOrderType, so oneplaceOrderrequest can expand into an execution schedule instead of a single resting order.TWAP goes through HyperLiquid’s native TWAP action/cancel. Scale submits a batch limit ladder and tracks children under a group handle. Chase is emulated client-side: a post-only order rests one tick inside the spread and is cancel/replaced as the touch moves, bounded by interval, duration, and repricing caps.
OrderResult.orderIdis a strategy handle for these types, with exchange ids inchildOrderIds.CancelOrderParams.orderTypeselects the strategy cancel path; omitting it keeps ordinary single-order cancel.editOrderrejects strategy edits. Invalid strategy params fail with new typedPERPS_ERROR_CODESbefore signing.Also breaks consumers that narrow
OrderType, narrows single-order helpers/closePositiontoOrdinaryOrderType, and spells outTriggerOrderTypeso the new types are not pulled into the trigger union.Reviewed by Cursor Bugbot for commit 2a22d0e. Bugbot is set up for automated code reviews on this repo. Configure here.