Skip to content

feat(perps): PerpsController advanced order types, proven e2e (stop market/limit, take-profit market/limit, reduce-only, partial TP/SL) - #9674

Open
abretonc7s wants to merge 26 commits into
mainfrom
TAT-3511-feat-add-perps-advanced-orders
Open

feat(perps): PerpsController advanced order types, proven e2e (stop market/limit, take-profit market/limit, reduce-only, partial TP/SL)#9674
abretonc7s wants to merge 26 commits into
mainfrom
TAT-3511-feat-add-perps-advanced-orders

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Explanation

@metamask/perps-controller could only place market and limit orders. TP/SL prices, reduceOnly,
timeInForce and order grouping already existed in the params model, but there was no way to place a
trigger order (stop / take profit) and no way to scope a TP/SL to part of a position — so the Pro
Mode order types could not be built in either client.

This adds those placement types to the controller contract, provider-agnostically:

  • OrderType gains stop_market, stop_limit, take_profit_market, take_profit_limit.
    OrderParams.triggerPrice sets the activation price: it is required for those four types and
    rejected on market/limit, so a stray trigger price can never be silently dropped. *_limit
    executes at OrderParams.price once triggered; *_market executes as a market order with a limit
    price derived from the trigger price, capped by maxSlippageBps (falling back to
    ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps).
  • reduceOnly becomes a first-class placement flag: validated, mapped, and round-tripped in
    open-orders state instead of only being set internally by close/flip flows.
  • Partial TP/SL via OrderParams.takeProfitSize / stopLossSize and
    UpdatePositionTPSLParams.takeProfitSize / stopLossSize. Omitting a size keeps today's
    whole-order/whole-position behaviour.
  • OrderParams.tpslLinkage ('none' | 'order' | 'position') replaces the HyperLiquid-shaped
    grouping without removing it: grouping is deprecated and still honoured, tpslLinkage takes
    precedence, and supplying both with different meanings is rejected with
    ORDER_TPSL_LINKAGE_CONFLICT rather than silently resolved.
  • Position state gains takeProfitOrders / stopLossOrders (PositionTriggerOrder[]): the
    complete view of the triggers attached to a position, with orderId, orderType, triggerPrice,
    size, isPartial and reduceOnly. The scalar takeProfitPrice / stopLossPrice fields carry one
    price each and cannot represent a partial TP/SL, so they stay for backward compatibility while the
    arrays become the complete shape. Open orders gain Order.triggerOrderType.
  • Typed errors, no silent failures: ORDER_TRIGGER_PRICE_REQUIRED,
    ORDER_TRIGGER_PRICE_POSITIVE, ORDER_TRIGGER_PRICE_NOT_SUPPORTED,
    ORDER_TRIGGER_TPSL_UNSUPPORTED, ORDER_TPSL_SIZE_INVALID, ORDER_EDIT_TRIGGER_UNSUPPORTED,
    ORDER_TPSL_LINKAGE_CONFLICT.

No HyperLiquid vocabulary (tpsl, triggerPx, isMarket, and now grouping) is required in
OrderParams — the protocol wording stays in orderCalculations / hyperLiquidAdapter / the provider,
so a second provider can map the same fields. No client UI here.

Notable package/API effects

  • OrderType is a wider union, so an exhaustive switch in a consumer now needs cases for the four
    new members. Every coarse orderType === 'limit' branch in this package was audited and routed
    through new isLimitExecutionOrderType / getTriggerExecution helpers, so a stop_limit is never
    treated as a market order for pricing, order-value limits, or fee tiers.
  • Order.orderType now reports how a trigger order executes instead of always reporting limit.
    HyperLiquid sets limitPx on trigger orders as a slippage cap, so a Stop Market /
    Take Profit Market row previously read back as a limit order. detailedOrderType is unchanged.
  • Partial TP/SL cannot be expressed under HyperLiquid's positionTpsl grouping (which always closes
    the whole position with size 0), so updatePositionTPSL submits it as standalone reduce-only
    trigger orders with na grouping and explicit sizes. In that path only, the pre-cancel sweep also
    clears previously placed standalone reduce-only triggers for the symbol so repeated calls stay
    idempotent — but never a TP/SL child of another pending order. The whole-position path is unchanged.
  • A position's trigger view (arrays and takeProfitCount / stopLossCount) uses one definition on both
    transports: reduce-only triggers on the market that are not another order's child, de-duplicated by
    order ID. Order.parentOrderId is now populated for real TP/SL children on the WebSocket stream so
    the streamed path can apply that rule.
  • editOrder rejects turning a resting order into a trigger placement
    (ORDER_EDIT_TRIGGER_UNSUPPORTED) rather than dropping the trigger, because HyperLiquid's modify
    rebuilds the order as a plain limit/market order.
  • getPositions also starts populating takeProfitCount / stopLossCount on the REST path, which
    previously always reported 0 there while the WebSocket path counted them.
  • PERPS_EVENT_VALUE.ORDER_TYPE lists the new placement types, which TradingService emits verbatim
    in the order_type analytics property.

Validation

Package suite: 73 suites / 2233 tests pass with coverage thresholds met. New tests cover the
order-type semantics, trigger pricing and SDK mapping, every validation rule, the read-back mapping,
and the provider end-to-end (place → mapped payload → cancel, partial TP/SL grouping and sizes,
position trigger arrays and the pending-order-child exclusion, typed error paths). Root yarn build
exits 0 and the emitted declarations carry the new options; eslint, oxfmt (yarn lint:misc) and
changelog:validate are clean.

Contract proven on real HyperLiquid orders, not mocks. An executable Recipe v1 proof
(e2e/recipes/advanced-orders.core.recipe.json, run with mm-harness run) drives the controller's
real signing/provider path: 63/63 nodes pass, 48 of them against a live venue. For every advanced
order type it places a real order, asserts it is visible in open-orders state with its trigger data
round-tripped from the exchange (placement type, trigger price, execution mode, reduce-only flag,
partial quantity), cancels it, and asserts the final state:

Type Real testnet order Trigger
stop market 57096142520 44000
stop limit 57096175797 45000
take-profit market 57096205568 90000
take-profit limit 57096236762 95000
reduce-only trigger 57096260783 43000
partial TP/SL (order-linked) 57096319817 child rests for its own 0.0002

It also opens a real testnet position and attaches partial TP and SL through updatePositionTPSL
(both triggers rest for the 0.0001 partial quantity, reduce-only), then cancels and closes. Mainnet is
covered as a cross-venue regression guard — read-back, a reduce-only trigger, and a small real position
with position-bound TP/SL — and the account's pre-existing orders are asserted untouched before and
after. A deterministic Jest guard (tests/src/e2e/advanced-orders.contract.test.ts) replays the same
matrix in CI so the contract cannot regress unnoticed; it was mutation-checked (breaking the trigger
read-back makes it fail).

Proving this natively required extending the recipe runner, committed separately in
metamask-harness (00e4f66): place_order now accepts the four trigger types plus trigger_price,
reduce_only, TP/SL sizes and tpsl_linkage; assert_orders gained expect_trigger_* assertions; and
a new update_position_tpsl action covers the position-bound half.

References

  • https://consensyssoftware.atlassian.net/browse/TAT-3511
  • Epic TAT-3273; initiative TAT-3229 (Advanced order types)
  • Recipe runner support for these placements: metamask-harness 00e4f66
  • Client follow-ups (order-screen UI for the new types) are separate tickets under the epic; this PR is
    the contract they depend on.

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
    • No breaking changes: all new params and state fields are optional, grouping still works, and
      existing call sites keep their behaviour. Two behaviour refinements are worth flagging to clients
      even though they are not API breaks — Order.orderType now reports the real execution kind for
      trigger orders, and OrderType is a wider union, so an exhaustive switch in a consumer needs the
      new cases.

Screenshots/Recordings


Note

High Risk
Changes core order placement, TP/SL lifecycle, and edit/read-back semantics on a live trading venue, including breaking OrderType and timeInForce behavior where mis-validation or mapping bugs could affect real funds.

Overview
@metamask/perps-controller now supports advanced trigger placements and quantity-scoped TP/SL through a provider-agnostic API, with HyperLiquid mapping and streaming/REST position state brought in line.

OrderType expands to stop_market, stop_limit, take_profit_market, and take_profit_limit, with OrderParams.triggerPrice required for those types and rejected on plain market/limit. reduceOnly, takeProfitSize/stopLossSize, and tpslLinkage (replacing deprecated grouping with conflict detection) are validated end-to-end through validateOrderParams, buildOrdersArray, and adaptOrderToSDK.

Position state gains takeProfitOrders/stopLossOrders (PositionTriggerOrder[]) plus aligned takeProfitCount/stopLossCount on REST and WebSocket; subscribers get updates when standalone/partial triggers change. updatePositionTPSL can place partial TP/SL as standalone triggers, defers signing until validation passes, and broadens pre-cancel sweeps for idempotent replaces.

Trading safety fixes: timeInForce is honored on limit orders (rejected elsewhere); editOrder fails closed on trigger edits and returns the replacement order id when identifiable; precision checks block zero-sized partial TP/SL and sub-tick prices before side effects.

A shared advanced-orders matrix runs in CI (simulated) and via tests/e2e/advanced-orders.e2e.ts (optional testnet), with new exports/helpers in orderTypes.ts and analytics PERPS_EVENT_VALUE.ORDER_TYPE entries for trigger types.

Breaking for consumers: widen any OrderType narrowing; handle optional editOrder orderId; stop passing timeInForce on non-limit orders.

Reviewed by Cursor Bugbot for commit 2575697. Bugbot is set up for automated code reviews on this repo. Configure here.

…only and partial TP/SL

Extend OrderType with stop_market, stop_limit, take_profit_market and
take_profit_limit, driven by a new OrderParams.triggerPrice: *_limit executes at
the limit price once triggered, *_market executes as a market order with a
slippage-capped limit price derived from the trigger. reduceOnly becomes a
first-class placement flag, and takeProfitSize/stopLossSize add quantity-scoped
(partial) TP/SL on both placement and updatePositionTPSL.

Position state gains takeProfitOrders/stopLossOrders, the complete trigger view
including partial triggers, which the scalar takeProfitPrice/stopLossPrice
fields cannot express; open orders gain Order.triggerOrderType. Trigger and
partial-size misuse now fails with typed errors rather than silently reshaping
the order, and editOrder rejects turning a resting order into a trigger
placement instead of dropping the trigger.

The params model stays provider-agnostic: no HyperLiquid vocabulary crosses into
OrderParams, so a second provider can map the same fields.

Also fixes Order.orderType for trigger orders, which always reported limit
because HyperLiquid sets limitPx on triggers as a slippage cap.

An e2e script proves place -> visible in open-orders state with the correct
trigger data -> cancel for every type and writes evidence artifacts; the same
matrix is replayed by a Jest contract guard so the contract cannot regress
unnoticed.
Restore oxfmt formatting on the changed files, which yarn lint enforces.

getPositions built its position trigger view from two independent sources
without de-duplication, so a pending order's TP/SL child — which HyperLiquid
lists both nested and at the top level — landed in the arrays twice and inflated
takeProfitCount/stopLossCount. Both sites are replaced by one helper that
de-duplicates by order ID and excludes another order's children, applying a
single definition across REST and WebSocket: a position's triggers are the
reduce-only triggers on that market which no other order owns. Order.parentOrderId
is now populated for real TP/SL children so the streamed path can apply the same
rule.

The partial TP/SL pre-cancel sweep short-circuited the isPositionTpsl check for
every reduce-only trigger on the symbol, so adjusting a position's partial TP/SL
could cancel a pending entry order's TP/SL. It now excludes children of other
orders, which requires the parent/child relationship the WebSocket cache cannot
express — partial updates therefore read the REST payload even with a warm cache.

Also: honour maxSlippageBps on market-executing triggers instead of silently
applying the 10% TP/SL default; reject an attached TP/SL on a trigger placement
even when the other price is an empty string; drop
normalizeExecutionOrderType, which duplicated getTriggerExecution; hoist a
position lookup map out of the WebSocket per-order loop; list the trigger
placement types in the analytics order_type enum; and widen the pending trade
configuration selector's declared orderType, which failed the package build.
The shipped recipe used the older core-adapter document shape, which the
harness rejects (recipe.unsupported_field). Rewritten against the Recipe v1
schema the runner validates, with the acceptance criteria expressed as nodes
over the regenerated per-type evidence, and verified with a full run:
39/39 nodes pass.

Typechecking uses tsc --build rather than the package build, because the
package build runs with --no-references and needs sibling dist/ output that
other slots in a shared checkout remove.
OrderParams.grouping spells HyperLiquid's own grouping vocabulary ('na',
'normalTpsl', 'positionTpsl'), so a provider-agnostic placement had to speak
protocol wording to say what an attached TP/SL belongs to.

tpslLinkage says it instead: 'none', 'order' or 'position', mapped onto the
exchange grouping by adaptTpslLinkageToGrouping inside the adapter layer.
grouping stays, deprecated and still honoured, so existing callers keep
working; tpslLinkage takes precedence, and supplying both with different
meanings is rejected with ORDER_TPSL_LINKAGE_CONFLICT rather than silently
resolved to one of them.

Also lists the trigger placement types in the analytics order_type enum, which
TradingService emits verbatim.
…cipe

The shipped recipe asserted against evidence produced by the package's own
e2e script. It now drives the harness's native perps actions instead, so each
advanced order type is placed as a REAL HyperLiquid order through the
controller's signing path, asserted in open-orders state with its trigger data
round-tripped from the exchange, cancelled, and its final state checked.

Adds position-bound coverage the wrapped evidence could not reach: a real
position with partial (quantity-scoped) TP/SL attached through
updatePositionTPSL, and mainnet validation — read-back, a reduce-only trigger,
then a small real position with position-bound TP/SL — with the account's
pre-existing orders asserted untouched before and after.
@abretonc7s
abretonc7s requested review from a team as code owners July 28, 2026 09:36
Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts Outdated
Comment thread packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts Outdated
OrderParams.timeInForce was accepted and then dropped: every limit order went
to the exchange as Gtc, so IOC and post-only ALO were silently impossible —
and post-only is what HyperLiquid requires during a post-only-only window.

GTC, IOC and ALO now map to their SDK values for plain limit orders, in both
buildOrdersArray and adaptOrderToSDK. Order shapes that cannot carry a time in
force — market orders and trigger placements, whose execution is decided by the
trigger — reject it instead of ignoring it, matching how a stray triggerPrice
is treated.

Also links the Unreleased changelog entries to the pull request.
Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
…me in force

Rejecting time in force on market and trigger orders threw a raw string, while
every other rejection in this area returns a PERPS_ERROR_CODES value that the
UI layer can translate. Adds ORDER_TIME_IN_FORCE_NOT_SUPPORTED and throws that
instead, and pulls the time-in-force mapping into a helper so the branches read
as a lookup rather than nested ternaries.

Also narrows the e2e lint and ignore patterns added by this branch from
packages/* to packages/perps-controller, since this package is the only one
introducing an e2e directory.
Rejecting an unsupported timeInForce happened while the exchange payload was
being built, which is step 7 of placeOrder. By then #prepareAssetForTrading has
already sent updateLeverage on-chain and #handleHip3PreOrder has moved margin to
a HIP-3 DEX, and neither is covered by the rollback that only runs once the
order is submitted. A caller passing timeInForce on a market order therefore got
a validation-shaped error while leverage had changed and funds sat stranded.

validateOrderParams now owns that rule, alongside every other placement rule, so
it fires before any state is touched; the throw in buildOrdersArray stays as a
backstop for direct callers of the mapper. Tests place with leverage set and
assert updateLeverage is never called on the rejection path.

The changelog now marks the timeInForce change breaking with its migration, and
documents the second throw adaptOrderToSDK gained.

getPositions grouped orders by market once instead of rescanning every order per
position, mirroring the map already used on the WebSocket path.

The limit_price analytics property now uses isLimitExecutionOrderType, so
stop_limit and take_profit_limit report the price their order_type dimension
already showed.

Position.takeProfitPrice and stopLossPrice document that they can still reflect
a pending order's TP/SL child and so disagree with the arrays and counts beside
them.
Streamed positions carry the new trigger arrays, but the change hash ignored
them. A standalone or partial trigger moves neither the scalar TP/SL fields nor
the legacy counts, so the hash was identical, positionsChanged stayed false, and
subscribers were never told — the arrays this change adds were undeliverable on
that event. The hash now folds in each array, keyed on order id, trigger price,
size and partial flag.

WebSocket counts came from tpslCountMap while the arrays came from the trigger
map, so the two disagreed for standalone and partial triggers. Counts now derive
from the same arrays the REST path counts, which makes the changelog's "one
definition on both transports" true; orders whose placement type HyperLiquid
does not name are absent from both, where the legacy count included them.

editOrder validated timeInForce and then rebuilt every limit order as Gtc,
reintroducing the silent drop this change set exists to remove. It now maps the
caller's value through the shared helper.

A falsy-but-present triggerPrice on a market or limit order was discarded rather
than rejected; it is now checked for presence, matching the attached-TP/SL rule
beside it.

Documents what could not be made safe by code alone: the OrderType widening is
marked breaking with the Mobile signatures that must widen, the partial TP/SL
pre-cancel spells out that it also clears standalone triggers the caller placed
itself, and ClosePositionParams notes that a trigger-based close is not
expressible.
editOrder rejected an edit into a trigger placement but never looked at the
order being modified, so editing a resting stop into a plain limit still went
through modify — replacing a protective stop with an immediately-resting order
and reporting success. It now rejects a resting trigger too, using the order
cache, which is the only local source that knows a placement type.

hashTriggerOrders moves to utils/orderTypes so it can be unit-tested directly:
empty and absent both hash to '0', and add, reprice, resize, partial and replace
each produce a different string. Reverting the hash terms now fails CI.

A partial TP/SL was validated against params.size but placed against a size
recomputed from a fresher price, so a child could exceed its parent on the
usdAmount path; the attached size is now clamped to the order it protects.

The time-in-force mapper existed twice, byte for byte; both callers now share
one implementation, placed in orderTypes because the adapter is imported by
orderCalculations and importing back would create a cycle.

Streamed positions emitted before any order tick carried no trigger arrays at
all, making "no triggers" and "not yet streamed" indistinguishable; they now
default to empty as they already do on REST.

The changelog names all six Mobile signatures that must widen, not four.
Two type errors reached CI because the local typecheck ran against stale
incremental build info and reported clean.

Defaulting the streamed trigger arrays inferred a position type whose arrays are
required, so assigning the merge result back — where they are optional — failed.
Annotating the binding as Position[] keeps the default without narrowing.

The limit_price analytics site on close events takes an optional order type,
which the widened helper does not accept; it is now guarded for presence first.
…sition TP/SL updates

A whole-position updatePositionTPSL only cancelled position-bound orders,
so standalone reduce-only triggers left by an earlier partial update
survived the replace and could fire beside the new positionTpsl orders.
The pre-cancel sweep now treats standalone triggers as the position's own
in both paths, and the warm-cache shortcut is only taken when the cache
shows no such trigger on the market, since telling one apart from another
order's TP/SL child needs the parent/child links only REST carries.
@abretonc7s

Copy link
Copy Markdown
Contributor Author

Automated dev run — TAT-3511

Metric Value
Run f263852d
Duration ?
Model claude/opus
Nudges 0
Worker report

Explanation

@metamask/perps-controller could only place market and limit orders. TP/SL prices, reduceOnly,
timeInForce and order grouping already existed in the params model, but there was no way to place a
trigger order (stop / take profit) and no way to scope a TP/SL to part of a position — so the Pro
Mode order types could not be built in either client.

This adds those placement types to the controller contract, provider-agnostically:

  • OrderType gains stop_market, stop_limit, take_profit_market, take_profit_limit.
    OrderParams.triggerPrice sets the activation price: it is required for those four types and
    rejected on market/limit, so a stray trigger price can never be silently dropped. *_limit
    executes at OrderParams.price once triggered; *_market executes as a market order with a limit
    price derived from the trigger price, capped by maxSlippageBps (falling back to
    ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps).
  • reduceOnly becomes a first-class placement flag: validated, mapped, and round-tripped in
    open-orders state instead of only being set internally by close/flip flows.
  • Partial TP/SL via OrderParams.takeProfitSize / stopLossSize and
    UpdatePositionTPSLParams.takeProfitSize / stopLossSize. Omitting a size keeps today's
    whole-order/whole-position behaviour.
  • OrderParams.tpslLinkage ('none' | 'order' | 'position') replaces the HyperLiquid-shaped
    grouping without removing it: grouping is deprecated and still honoured, tpslLinkage takes
    precedence, and supplying both with different meanings is rejected with
    ORDER_TPSL_LINKAGE_CONFLICT rather than silently resolved.
  • Position state gains takeProfitOrders / stopLossOrders (PositionTriggerOrder[]): the
    complete view of the triggers attached to a position, with orderId, orderType, triggerPrice,
    size, isPartial and reduceOnly. The scalar takeProfitPrice / stopLossPrice fields carry one
    price each and cannot represent a partial TP/SL, so they stay for backward compatibility while the
    arrays become the complete shape. Open orders gain Order.triggerOrderType.
  • Typed errors, no silent failures: ORDER_TRIGGER_PRICE_REQUIRED,
    ORDER_TRIGGER_PRICE_POSITIVE, ORDER_TRIGGER_PRICE_NOT_SUPPORTED,
    ORDER_TRIGGER_TPSL_UNSUPPORTED, ORDER_TPSL_SIZE_INVALID, ORDER_EDIT_TRIGGER_UNSUPPORTED,
    ORDER_TPSL_LINKAGE_CONFLICT.

No HyperLiquid vocabulary (tpsl, triggerPx, isMarket, and now grouping) is required in
OrderParams — the protocol wording stays in orderCalculations / hyperLiquidAdapter / the provider,
so a second provider can map the same fields. No client UI here.

Notable package/API effects

  • OrderType is a wider union, so an exhaustive switch in a consumer now needs cases for the four
    new members. Every coarse orderType === 'limit' branch in this package was audited and routed
    through new isLimitExecutionOrderType / getTriggerExecution helpers, so a stop_limit is never
    treated as a market order for pricing, order-value limits, or fee tiers.
  • Order.orderType now reports how a trigger order executes instead of always reporting limit.
    HyperLiquid sets limitPx on trigger orders as a slippage cap, so a Stop Market /
    Take Profit Market row previously read back as a limit order. detailedOrderType is unchanged.
  • Partial TP/SL cannot be expressed under HyperLiquid's positionTpsl grouping (which always closes
    the whole position with size 0), so updatePositionTPSL submits it as standalone reduce-only
    trigger orders with na grouping and explicit sizes. In that path only, the pre-cancel sweep also
    clears previously placed standalone reduce-only triggers for the symbol so repeated calls stay
    idempotent — but never a TP/SL child of another pending order. The whole-position path is unchanged.
  • A position's trigger view (arrays and takeProfitCount / stopLossCount) uses one definition on both
    transports: reduce-only triggers on the market that are not another order's child, de-duplicated by
    order ID. Order.parentOrderId is now populated for real TP/SL children on the WebSocket stream so
    the streamed path can apply that rule.
  • editOrder rejects turning a resting order into a trigger placement
    (ORDER_EDIT_TRIGGER_UNSUPPORTED) rather than dropping the trigger, because HyperLiquid's modify
    rebuilds the order as a plain limit/market order.
  • getPositions also starts populating takeProfitCount / stopLossCount on the REST path, which
    previously always reported 0 there while the WebSocket path counted them.
  • PERPS_EVENT_VALUE.ORDER_TYPE lists the new placement types, which TradingService emits verbatim
    in the order_type analytics property.

Validation

Package suite: 73 suites / 2233 tests pass with coverage thresholds met. New tests cover the
order-type semantics, trigger pricing and SDK mapping, every validation rule, the read-back mapping,
and the provider end-to-end (place → mapped payload → cancel, partial TP/SL grouping and sizes,
position trigger arrays and the pending-order-child exclusion, typed error paths). Root yarn build
exits 0 and the emitted declarations carry the new options; eslint, oxfmt (yarn lint:misc) and
changelog:validate are clean.

Contract proven on real HyperLiquid orders, not mocks. An executable Recipe v1 proof
(e2e/recipes/advanced-orders.core.recipe.json, run with mm-harness run) drives the controller's
real signing/provider path: 63/63 nodes pass, 48 of them against a live venue. For every advanced
order type it places a real order, asserts it is visible in open-orders state with its trigger data
round-tripped from the exchange (placement type, trigger price, execution mode, reduce-only flag,
partial quantity), cancels it, and asserts the final state:

Type Real testnet order Trigger
stop market 57096142520 44000
stop limit 57096175797 45000
take-profit market 57096205568 90000
take-profit limit 57096236762 95000
reduce-only trigger 57096260783 43000
partial TP/SL (order-linked) 57096319817 child rests for its own 0.0002

It also opens a real testnet position and attaches partial TP and SL through updatePositionTPSL
(both triggers rest for the 0.0001 partial quantity, reduce-only), then cancels and closes. Mainnet is
covered as a cross-venue regression guard — read-back, a reduce-only trigger, and a small real position
with position-bound TP/SL — and the account's pre-existing orders are asserted untouched before and
after. A deterministic Jest guard (tests/src/e2e/advanced-orders.contract.test.ts) replays the same
matrix in CI so the contract cannot regress unnoticed; it was mutation-checked (breaking the trigger
read-back makes it fail).

Proving this natively required extending the recipe runner, committed separately in
metamask-harness (00e4f66): place_order now accepts the four trigger types plus trigger_price,
reduce_only, TP/SL sizes and tpsl_linkage; assert_orders gained expect_trigger_* assertions; and
a new update_position_tpsl action covers the position-bound half.

References

  • https://consensyssoftware.atlassian.net/browse/TAT-3511
  • Epic TAT-3273; initiative TAT-3229 (Advanced order types)
  • Recipe runner support for these placements: metamask-harness 00e4f66
  • Client follow-ups (order-screen UI for the new types) are separate tickets under the epic; this PR is
    the contract they depend on.

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
    • No breaking changes: all new params and state fields are optional, grouping still works, and
      existing call sites keep their behaviour. Two behaviour refinements are worth flagging to clients
      even though they are not API breaks — Order.orderType now reports the real execution kind for
      trigger orders, and OrderType is a wider union, so an exhaustive switch in a consumer needs the
      new cases.

@abretonc7s abretonc7s changed the title feat(perps-controller): add stop/take-profit placement types, reduce-only and partial TP/SL feat(perps): [Core] PerpsController advanced order types, proven e2e (stop market/limit, take-profit market/limit, reduce-only, partial TP/SL) Jul 28, 2026
The whole-position TP/SL sweep now falls back to REST whenever the warm
cache shows a standalone-looking trigger, so the cache-path test that fed
the cache another order's normalTpsl children no longer took that branch
and cancelled nothing. It now supplies the REST payload with the
parent/child links and asserts the same guarantee through the fallback:
only the position-bound orders are cancelled.

Also reformat the multi-line fallback condition, which failed
lint:misc:check, and drop the .gitignore and eslint.config.mjs entries for
packages/perps-controller/e2e — that directory no longer exists.
Partial TP/SL sizes were validated as requested, not as submitted. A
positive size below the asset's precision (0.0004 against szDecimals: 3)
formatted to '0', which HyperLiquid reads as covering the whole position —
turning a partial TP/SL into a full close. Both placeOrder and
updatePositionTPSL now reject a size that rounds away.

Attaching TP/SL to an ordinary order with position linkage built a
positionTpsl batch containing the non-trigger parent, which HyperLiquid
rejects wholesale. validateOrderParams refuses the combination up front and
points at updatePositionTPSL.

editOrder verified the resting order's placement type only from the
WebSocket order cache, so a cold or stale cache let the edit proceed and
rebuild a protective stop as a plain order. It now falls back to
frontendOpenOrders and fails closed when the order cannot be verified.

adaptOrderToSDK emitted p: '0' for stop_market / take_profit_market orders,
which legitimately carry no limit price. That field is the slippage cap the
SDK still requires, so it is now derived from the trigger price.
Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
…fect

The partial-size precision check only fired while the orders were being
built, which in updatePositionTPSL is after the pre-cancel sweep has already
cleared the position's existing triggers — so a size that disappeared at the
asset precision stripped the protection and returned
ORDER_TPSL_SIZE_INVALID with nothing put back. In placeOrder the same throw
sat after the signing prompts, the leverage change, and the HIP-3 margin
transfer.

Both paths now check the formatted size as soon as szDecimals is known and
before anything is committed. updatePositionTPSL resolves the asset metadata
above the sweep instead of below it; placeOrder checks straight after
#getAssetInfo. The throw inside formatPartialTpslSize stays as a last-resort
guard on the build path.
An attached TP/SL with no linkage ('na' grouping, or tpslLinkage: 'none')
was accepted and submitted as standalone triggers bound to neither the
parent order nor the resulting position. An unfilled parent left them behind
as orphan reduce-only triggers that fire against whatever position happens
to exist, so validateOrderParams now refuses the combination with
ORDER_TPSL_LINKAGE_REQUIRED.

updatePositionTPSL ran its trading setup first, so an update rejected for an
invalid partial size had already prompted for signatures and written the
referral and builder-fee approvals. It now does basic initialization up
front and defers that setup until every validation has passed, immediately
before the pre-cancel sweep.

The trigger cap adaptOrderToSDK derives for market-on-trigger orders ignored
the order's own tolerance, so a caller asking for a tighter bound silently
got the 10% default — a materially different execution bound from the same
order through placeOrder. It now reads maxSlippageBps, and the deprecated
decimal slippage, before falling back to the default.
Position linkage was only rejected when the order carried an attached TP/SL,
so requesting it on its own still built a positionTpsl batch — one holding
just the ordinary parent order, which HyperLiquid rejects for the same
reason as the attached case. No order placement produces a batch of only
trigger orders, so the linkage is now refused outright and callers are
pointed at updatePositionTPSL.

The trigger hash behind streamed positions covered order ID, trigger price,
size, and partial-ness, but not the placement type. A trigger modified in
place from market to limit execution keeps the first four, so the hash never
moved and the notification was suppressed, leaving subscribers on stale
execution semantics.
An asset quotes to MaxPriceDecimals - szDecimals places, so a positive price
under that tick formatted to '0' and was submitted as a zero triggerPx. The
SDK rejects that, but only once placeOrder had already completed trading
setup, changed leverage, and possibly moved HIP-3 margin — the same
after-the-side-effects failure the partial-size check was moved to avoid.

Prices are now checked alongside the sizes, in the one place that already
runs as soon as szDecimals is known and before anything is committed. The
helper covers both, so it is renamed validateOrderPrecision.

The formatter guard on the build path is extended to match: every price that
becomes a triggerPx now goes through formatTriggerPrice, which throws rather
than emitting '0'. That covers the attached take profit and stop loss
prices, which had no positivity check of their own at all and reached the
exchange the same way.
…flag

adaptOrderFromSDK collapses HyperLiquid's "whole position" encoding (size 0)
against the position as it stood when the order was adapted, so the size an
Order carries goes stale the moment the position is resized. Reading that
number back meant a position-bound TP/SL reported the old size, and was
called partial whenever the position had since grown — a trigger that still
covered all of it.

The isPositionTpsl flag is the durable statement of what the trigger covers,
so resolve from it and let the number follow.
HyperLiquid sometimes reports a bare 'Trigger', naming neither direction nor
execution. Such an order was dropped from takeProfitOrders / stopLossOrders
while the legacy count still counted it, so a streamed position could report
a count of 1 beside an empty array — a state no subscriber can render.

The direction is recoverable from the trigger price against the entry, and
it is the only part that decides which array an order belongs to. So
PositionTriggerOrder now states direction, always, and orderType only when
the exchange named the placement type: the execution mode of an unnamed
trigger cannot be recovered and is left unstated rather than guessed. Both
transports share one classifier, so REST and WebSocket agree, and the counts
now derive from the arrays in every case rather than only when the symbol
happened to have an entry.

The subscription suite stubbed adaptOrderFromSDK without triggerOrderType,
which left its trigger arrays empty whatever the code under test did. The
legacy count masked that, so the tests passed while asserting a shape the
adapter never produces. The stub now names the placement type the way the
real adapter does.
Comment thread packages/perps-controller/src/utils/orderTypes.ts Outdated
… venue

The contract had a proof, but it lived outside the repo it covered and only
ran when someone remembered to copy it in — so it guarded nothing. It now
sits under tests/, where the Jest guard runs in the normal suite and the
matrix it shares with the testnet script typechecks against the code it
describes. Moving it there immediately surfaced eight type errors and an
undeclared import that had been invisible.

Nothing in the matrix names a market value any more. Prices are offsets from
the venue's live mid, sizes a share of the order floored at what the market's
precision can express, and the asset index is read from the venue's meta.
Every one of those had been hardcoded for a $50k BTC, and each was wrong:
testnet quotes BTC to five size decimals rather than three, and asset index 0
is SOL, so the old constants submitted BTC-priced orders against the wrong
market.

The matrix also probes the venue behaviours the controller's refusals rest
on, by hand-building the payloads those refusals prevent. All five hold: a
positionTpsl batch containing a plain order is rejected, a zero triggerPx is
rejected, a zero cap price on a market-on-trigger order is rejected, a
zero-size trigger is read as covering the whole position, and na-grouped
children outlive a cancelled parent. Each guard added while reviewing this PR
is now justified by observed behaviour rather than assumption.

Simulated runs report those probes as skipped rather than passing them, since
the in-process double echoes payloads back and can never reject one.
--require-premises turns a skip into a failure for an acceptance run.
@socket-security

socket-security Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedviem@​2.48.4 ⏵ 2.55.89710010098 +1100

View full report

@socket-security

socket-security Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

MetaMask internal reviewing guidelines:

  • Do not ignore-all
  • Each alert has instructions on how to review if you don't know what it means. If lost, ask your Security Liaison or the supply-chain group
  • Copy-paste ignore lines for specific packages or a group of one kind with a note on what research you did to deem it safe.
    @SocketSecurity ignore npm/PACKAGE@VERSION
Action Severity Alert  (click "▶" to expand/collapse)
Warn Low
Potential code anomaly (AI signal): npm ox is 62.0% likely to have a medium risk anomaly

Notes: This fragment is primarily a CPU-intensive proof-of-work/salt-mining implementation using worker-thread parallelism plus an async fallback. It includes input validation, structured error propagation, and abort handling, and it does not show classic malware behaviors (no network/file/process/persistence or dynamic execution in the snippet). The dominant security concern is potential resource-exhaustion/DoS if untrusted callers can control workerCount/count/chunkSize, and secondary concern is leakage of progress/rate metrics into application callbacks/logging. Overall: likely intended PoW functionality but potentially abuse-prone in the wrong threat model.

Confidence: 0.62

Severity: 0.50

From: ?npm/viem@2.55.8npm/ox@0.14.32

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/ox@0.14.32. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm ox is 60.0% likely to have a medium risk anomaly

Notes: This module implements parallel WebAssembly computation using Node worker_threads and browser Web Workers, including dynamic worker script execution (Node eval:true and browser Blob URL). It communicates only via postMessage and does not show network exfiltration, credential theft, or persistence within this snippet. The main risks are supply-chain/execution boundary concerns from dynamic worker code and potential CPU/DoS impact if the mining parameters are attacker-influenced. Overall: likely intended for compute work, but should be reviewed and guarded with strict input controls and hardened worker creation.

Confidence: 0.60

Severity: 0.60

From: ?npm/viem@2.55.8npm/ox@0.14.32

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/ox@0.14.32. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm ox is 70.0% likely to have a medium risk anomaly

Notes: This dependency is a cross-platform worker harness that executes embedded WebAssembly to perform a “salt mining” computation and returns progress/results to the caller via message passing. In this file, there is no clear evidence of classic malware behaviors such as network exfiltration, credential theft, or filesystem/system sabotage. The most notable supply-chain/security concerns are dynamic code execution patterns (Node Worker with eval:true and browser Blob URL worker scripts) and the potential for CPU-intensive abuse (computational mining-like workload) if invoked in an unauthorized context or with adversarial parameters. Overall: moderate security risk driven by execution surface and availability impact rather than direct data-stealing.

Confidence: 0.70

Severity: 0.60

From: ?npm/viem@2.55.8npm/ox@0.14.32

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/ox@0.14.32. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm ox is 66.0% likely to have a medium risk anomaly

Notes: This dependency is a worker-based “salt mining”/proof-of-work compute engine that loads an embedded WebAssembly payload and runs a CPU-intensive loop in Node worker_threads or browser Web Workers, communicating progress and results via postMessage. There is no direct evidence in this fragment of network exfiltration, credential access, persistence, or system modification. The main security concerns are (1) dynamic worker code execution (Node worker eval:true and browser Blob URL execution) and (2) cryptomining-like resource consumption that can be abused for CPU exhaustion. The embedded WASM module itself should be reviewed to confirm it contains only the expected computation and no hidden side effects.

Confidence: 0.66

Severity: 0.55

From: ?npm/viem@2.55.8npm/ox@0.14.32

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/ox@0.14.32. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm viem is 75.0% likely to have a medium risk anomaly

Notes: The code implements a cross-chain deposit flow with proper validations, artifact reads, and on-chain interactions. There is no evidence of hidden backdoors, data exfiltration, or malware. The main security considerations relate to token approval logic and correct configuration of flags to avoid granting excessive allowances. Overall, the module appears legitimate for a bridge deposit flow, with moderate risk primarily around configuration of approvals and correct handling of gas/fees.

Confidence: 0.75

Severity: 0.60

From: packages/perps-controller/package.jsonnpm/viem@2.55.8

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/viem@2.55.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Comment thread packages/perps-controller/tests/helpers/advancedOrders.ts
… twice

classifyTriggerDirection called a short trigger sitting exactly at entry a
take profit, while the legacy price fallback behind the scalar
takeProfitPrice/stopLossPrice fields called it a stop. The same order could
therefore appear in takeProfitOrders while the scalar still reported a stop —
the two views disagreeing about one order, which having a single classifier
was supposed to prevent.

Both sides now fall to stop at the tie, matching the fallback.
The proof script is run directly rather than imported, so knip could not see
it and reported the dependency only it uses as unused. Declaring it as an
entry point makes the dependency provably used, which also retired an
ignoreUnresolved entry that had been covering for the gap. Deduped the
lockfile and applied the repo formatter the script had missed.
@abretonc7s abretonc7s changed the title feat(perps): [Core] PerpsController advanced order types, proven e2e (stop market/limit, take-profit market/limit, reduce-only, partial TP/SL) feat(perps): PerpsController advanced order types, proven e2e (stop market/limit, take-profit market/limit, reduce-only, partial TP/SL) Jul 31, 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 advanced order implementation is well covered in several new test files, but this branch changes the runtime behavior of an existing public OrderParams.grouping value while the public type and changelog still state that the deprecated field remains honored. That should be resolved before merge because it can break current consumers without a type error or migration path.

// that parent order alone. HyperLiquid rejects both, so the linkage is
// refused outright — it belongs to `updatePositionTPSL`, applied to the
// position once the parent has filled.
const requestsPositionLinkage =

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.

This new validation rejects params.grouping === 'positionTpsl', which breaks an existing OrderParams.grouping value that was accepted on origin/main and passed through to buildOrdersArray.

grouping is still a public OrderParams field, and the changelog says it is deprecated but still honored so existing callers keep working. With this branch, a caller that currently places an order with takeProfitPrice plus grouping: 'positionTpsl' now receives ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED before placement. That is a runtime compatibility regression introduced by this validation hunk.

Please either keep the legacy grouping: 'positionTpsl' path working for existing callers, or document and version this as a breaking API change. The provider tests around this path currently mock validateOrderParams, so they still expect positionTpsl to be submitted even though the real validator rejects it.

…edit

HyperLiquid does not edit an order in place. `modify` cancels the target and
rests a replacement under a new oid, and the SDK's modify response carries no
oid, so returning `params.orderId` named an order the venue had already
cancelled — while `OrderResult.orderId` is documented as the exchange order ID.

Resolve the replacement from the open orders read after the modify, returning it
only when exactly one newly-rested order carries the submitted market, side and
size. Novelty is judged against a snapshot taken before the modify, so an order
that was already resting with the same attributes cannot be mistaken for the
replacement.

When identity cannot be established unambiguously — a market edit that filled
rather than rested, a read that has not caught up, or several candidates — the
result stays successful and the optional id is omitted rather than reporting one
that may be wrong. A failed post-modify read is likewise not allowed to turn a
successful edit into a failure.

`#findRestingOrder` is folded into the single pre-edit read it now shares with
the replacement baseline.

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

// One authoritative read of what is resting before the edit. It verifies
// the target when the cache could not, and doubles as the baseline that
// tells the replacement apart from orders that were already there.
const ordersBeforeEdit = await this.#fetchOpenOrders({ dexName });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Edit blocked by baseline fetch

Medium Severity

ordersBeforeEdit is always loaded before modify, including when the WebSocket cache already confirmed the resting order is safe to edit. A failure of that read aborts the whole edit, even though the snapshot is only needed later to optionally resolve orderId. The post-modify lookup already soft-fails and omits the id; a baseline failure on the cache-hit path has the same optional purpose but hard-fails a modify that could still succeed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2575697. Configure here.

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.

2 participants