feat(perps): PerpsController advanced order types, proven e2e (stop market/limit, take-profit market/limit, reduce-only, partial TP/SL) - #9674
Conversation
…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.
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.
…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.
Automated dev run — TAT-3511
Worker reportExplanation
This adds those placement types to the controller contract, provider-agnostically:
No HyperLiquid vocabulary ( Notable package/API effects
ValidationPackage suite: 73 suites / 2233 tests pass with coverage thresholds met. New tests cover the Contract proven on real HyperLiquid orders, not mocks. An executable Recipe v1 proof
It also opens a real testnet position and attaches partial TP and SL through Proving this natively required extending the recipe runner, committed separately in References
Checklist
|
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.
…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.
… 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.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning MetaMask internal reviewing guidelines:
|
… 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.
geositta
left a comment
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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.
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 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 }); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 2575697. Configure here.


Explanation
@metamask/perps-controllercould only placemarketandlimitorders. TP/SL prices,reduceOnly,timeInForceand order grouping already existed in the params model, but there was no way to place atrigger 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:
OrderTypegainsstop_market,stop_limit,take_profit_market,take_profit_limit.OrderParams.triggerPricesets the activation price: it is required for those four types andrejected on
market/limit, so a stray trigger price can never be silently dropped.*_limitexecutes at
OrderParams.priceonce triggered;*_marketexecutes as a market order with a limitprice derived from the trigger price, capped by
maxSlippageBps(falling back toORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps).reduceOnlybecomes a first-class placement flag: validated, mapped, and round-tripped inopen-orders state instead of only being set internally by close/flip flows.
OrderParams.takeProfitSize/stopLossSizeandUpdatePositionTPSLParams.takeProfitSize/stopLossSize. Omitting a size keeps today'swhole-order/whole-position behaviour.
OrderParams.tpslLinkage('none' | 'order' | 'position') replaces the HyperLiquid-shapedgroupingwithout removing it:groupingis deprecated and still honoured,tpslLinkagetakesprecedence, and supplying both with different meanings is rejected with
ORDER_TPSL_LINKAGE_CONFLICTrather than silently resolved.takeProfitOrders/stopLossOrders(PositionTriggerOrder[]): thecomplete view of the triggers attached to a position, with
orderId,orderType,triggerPrice,size,isPartialandreduceOnly. The scalartakeProfitPrice/stopLossPricefields carry oneprice 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.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 nowgrouping) is required inOrderParams— the protocol wording stays inorderCalculations/hyperLiquidAdapter/ the provider,so a second provider can map the same fields. No client UI here.
Notable package/API effects
OrderTypeis a wider union, so an exhaustiveswitchin a consumer now needs cases for the fournew members. Every coarse
orderType === 'limit'branch in this package was audited and routedthrough new
isLimitExecutionOrderType/getTriggerExecutionhelpers, so astop_limitis nevertreated as a market order for pricing, order-value limits, or fee tiers.
Order.orderTypenow reports how a trigger order executes instead of always reportinglimit.HyperLiquid sets
limitPxon trigger orders as a slippage cap, so aStop Market/Take Profit Marketrow previously read back as a limit order.detailedOrderTypeis unchanged.positionTpslgrouping (which always closesthe whole position with size
0), soupdatePositionTPSLsubmits it as standalone reduce-onlytrigger orders with
nagrouping and explicit sizes. In that path only, the pre-cancel sweep alsoclears 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.
takeProfitCount/stopLossCount) uses one definition on bothtransports: reduce-only triggers on the market that are not another order's child, de-duplicated by
order ID.
Order.parentOrderIdis now populated for real TP/SL children on the WebSocket stream sothe streamed path can apply that rule.
editOrderrejects turning a resting order into a trigger placement(
ORDER_EDIT_TRIGGER_UNSUPPORTED) rather than dropping the trigger, because HyperLiquid'smodifyrebuilds the order as a plain limit/market order.
getPositionsalso starts populatingtakeProfitCount/stopLossCounton the REST path, whichpreviously always reported
0there while the WebSocket path counted them.PERPS_EVENT_VALUE.ORDER_TYPElists the new placement types, whichTradingServiceemits verbatimin the
order_typeanalytics 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 buildexits 0 and the emitted declarations carry the new options;
eslint,oxfmt(yarn lint:misc) andchangelog:validateare clean.Contract proven on real HyperLiquid orders, not mocks. An executable Recipe v1 proof
(
e2e/recipes/advanced-orders.core.recipe.json, run withmm-harness run) drives the controller'sreal 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:
570961425205709617579757096205568570962367625709626078357096319817It 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 samematrix 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_ordernow accepts the four trigger types plustrigger_price,reduce_only, TP/SL sizes andtpsl_linkage;assert_ordersgainedexpect_trigger_*assertions; anda new
update_position_tpslaction covers the position-bound half.References
metamask-harness00e4f66the contract they depend on.
Checklist
groupingstill works, andexisting call sites keep their behaviour. Two behaviour refinements are worth flagging to clients
even though they are not API breaks —
Order.orderTypenow reports the real execution kind fortrigger orders, and
OrderTypeis a wider union, so an exhaustiveswitchin a consumer needs thenew 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
OrderTypeandtimeInForcebehavior where mis-validation or mapping bugs could affect real funds.Overview
@metamask/perps-controllernow 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.OrderTypeexpands tostop_market,stop_limit,take_profit_market, andtake_profit_limit, withOrderParams.triggerPricerequired for those types and rejected on plainmarket/limit.reduceOnly,takeProfitSize/stopLossSize, andtpslLinkage(replacing deprecatedgroupingwith conflict detection) are validated end-to-end throughvalidateOrderParams,buildOrdersArray, andadaptOrderToSDK.Position state gains
takeProfitOrders/stopLossOrders(PositionTriggerOrder[]) plus alignedtakeProfitCount/stopLossCounton REST and WebSocket; subscribers get updates when standalone/partial triggers change.updatePositionTPSLcan place partial TP/SL as standalone triggers, defers signing until validation passes, and broadens pre-cancel sweeps for idempotent replaces.Trading safety fixes:
timeInForceis honored on limit orders (rejected elsewhere);editOrderfails 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 inorderTypes.tsand analyticsPERPS_EVENT_VALUE.ORDER_TYPEentries for trigger types.Breaking for consumers: widen any
OrderTypenarrowing; handle optionaleditOrderorderId; stop passingtimeInForceon non-limit orders.Reviewed by Cursor Bugbot for commit 2575697. Bugbot is set up for automated code reviews on this repo. Configure here.