Skip to content

feat(perps): [perps-controller] Add Chase, TWAP, and Scale order types - #9832

Merged
abretonc7s merged 20 commits into
mainfrom
TAT-3723-feat-add-twap-scale-chase-orders
Aug 12, 2026
Merged

feat(perps): [perps-controller] Add Chase, TWAP, and Scale order types#9832
abretonc7s merged 20 commits into
mainfrom
TAT-3723-feat-add-twap-scale-chase-orders

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Explanation

@metamask/perps-controller supported six order types — market, limit, and the four trigger
placements — 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, scale and chase to OrderType, with the parameters, validation, placement and
cancellation 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 own
endpoints); 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", so
validation enforces the real bound.

What each placement does.

  • TWAP is submitted through the venue's TWAP action rather than the order book, and returns the
    venue's TWAP id. Cancelling it uses the TWAP cancel endpoint, never the order-book cancel.
  • Scale fans out scaleNumOrders limit orders on an inclusive ladder between scaleMinPrice and
    scaleMaxPrice, submitted as one batch so the ladder rests together or fails together. Sizes are
    split 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.
  • Chase rests a post-only order at the near touch and returns a session handle immediately; a
    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. OrderType is a wider union, which is breaking in the same way the trigger
types were in 11.0.0: consumer signatures that narrow it back to a smaller set must widen. OrderParams
gains eight optional strategy fields, OrderResult gains childOrderIds, and CancelOrderParams
gains an optional orderType that selects the cancellation path — omitting it, which every existing
caller 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_CODES value before any network call.

The public model stays provider-agnostic — no venue vocabulary reaches OrderParams,
OrderResult or CancelOrderParams. The one genuinely venue-specific constant is named for its
venue, HYPERLIQUID_TWAP_LIMITS, beside the existing HYPERLIQUID_ORDER_LIMITS.

One incidental fix: TriggerOrderType was Exclude<OrderType, 'market' | 'limit'>, so the three new
members 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 childOrderIds after a restart); TWAP progress is not surfaced in controller
state, 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

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them
    • The OrderType widening is breaking in the same way as the trigger types in 11.0.0 and is marked
      BREAKING 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 OrderType union (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, and chase to OrderType, so one placeOrder request 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.orderId is a strategy handle for these types, with exchange ids in childOrderIds. CancelOrderParams.orderType selects the strategy cancel path; omitting it keeps ordinary single-order cancel. editOrder rejects strategy edits. Invalid strategy params fail with new typed PERPS_ERROR_CODES before signing.

Also breaks consumers that narrow OrderType, narrows single-order helpers/closePosition to OrdinaryOrderType, and spells out TriggerOrderType so 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.

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 abretonc7s changed the title chore: prepare farmslot publication pkg-f4c8f6ef-mspgcfnc feat(perps): [perps-controller] Add Chase, TWAP, and Scale order types Aug 12, 2026
@abretonc7s
abretonc7s marked this pull request as ready for review August 12, 2026 02:21
@abretonc7s
abretonc7s requested review from a team as code owners August 12, 2026 02:21

@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 e0883cc. Configure here.

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
geositta
geositta previously approved these changes Aug 12, 2026

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

Comment thread packages/perps-controller/src/constants/perpsConfig.ts
@abretonc7s
abretonc7s enabled auto-merge August 12, 2026 09:03
@abretonc7s
abretonc7s added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 785ff8c Aug 12, 2026
46 checks passed
@abretonc7s
abretonc7s deleted the TAT-3723-feat-add-twap-scale-chase-orders branch August 12, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants