feat(perps-controller): add subscribeToTwapOrders - #10056
Conversation
28f2c92 to
1ba1d53
Compare
TWAP was read-only through getTwapOrders(), so clients had to poll two venue REST calls to monitor a running schedule. HyperLiquid already pushes the same twapHistory payload over its userTwapHistory channel; this exposes it. - SubscribeTwapOrdersParams delivers the TwapOrder[] shape getTwapOrders returns, so a client swaps a poll for a subscription without reshaping state - PerpsProvider.subscribeToTwapOrders is optional; providers without a native push channel return a no-op cleanup rather than forcing stubs - Concurrent subscribers for one account share a single socket, and the subscription is re-established after a reconnect - The service owns transport and fan-out only; the provider supplies the adapter, so venue-shape knowledge stays where getTwapOrders keeps it Ref: https://consensyssoftware.atlassian.net/browse/TAT-3903
…cription Five defects found by independent Codex and Claude review passes: - The venue sends a snapshot then deltas, but every push was treated as the complete set, so a schedule absent from a delta was dropped from the consumer's state. Merge by orderId; a fresh snapshot replaces. - Last-subscriber teardown could not cancel an open still in flight, so the continuation installed a socket with no subscribers and every frame was silently discarded. Guarded by a generation counter plus a subscriber recheck before registering. - clearAll() and the reconnect path cleared the maps without invalidating in-flight opens, letting a stale resolution repopulate them after teardown or unsubscribe the live replacement. Both now bump the generation, matching the adjacent spot-state guard. - Streamed terminal schedules never reclaimed HIP-3 collateral, which getTwapOrders() does on every read, so a consumer that replaced polling could strand manually transferred collateral. - The aggregated provider did not stamp providerId on streamed schedules, unlike getTwapOrders, breaking the advertised swap. Ref: https://consensyssoftware.atlassian.net/browse/TAT-3903
A second cross-review round found the generation counter introduced by the previous fix was itself unsound. Three independent defects: - The counter was a global scalar guarding per-account state, and one bump site sat inside the per-account unsubscribe closure. One account tearing down made every other account's in-flight open discard itself, leaving live subscribers with no subscription and no retry. It is now keyed by account, matching every other TWAP map. - The client-bootstrap path recursed, re-reading the generation after the handshake and adopting a bump that landed during it as its own baseline. The bootstrap is now inline and the generation is captured once, spanning the whole open. - The pending-map cleanup deleted by key, so a stale open's finally could evict a newer in-flight open installed by a reconnect. It now deletes only its own promise. Also: a caller awaiting a peer's open now re-checks that a subscription landed, since the discard path resolves rather than rejects; the merged cache is bounded like the fills cache; the aggregated provider returns its documented no-op instead of throwing when no default provider exists; and the JSDoc that contradicted the empty-fills behaviour is corrected. Adds the multi-account and reconnect coverage whose absence let the global-scalar defect ship. Ref: https://consensyssoftware.atlassian.net/browse/TAT-3903
A third cross-review round found the retention cap added last round was worse than the unbounded growth it replaced. The merge dedupes on lastUpdated but the cap sorted by startedAt. A long-running active schedule has the oldest startedAt of all, so 100 newer terminal schedules would evict the one TWAP the user is actually monitoring — and because the truncated set was written back to the cache, a later delta for the evicted order was re-added and then evicted again in the same callback, so it could never come back. The cap now applies to terminal schedules only; active ones are never evicted. Also from that round: - clearAll bumped generations from a union that included the subscriber keys, but read it after those keys had been cleared, so the term contributed nothing. The bump is hoisted above every clear. - The streamed HIP-3 reclaim resolved the user address inside its detached microtask, so an account switch racing a terminal push keyed the reclaim to the wrong account and silently skipped the schedule. The address is now resolved from the push's own scope before detaching. - The cap comment claimed to match the fills cache bound; it does not, and the difference is what caused the eviction defect. Adds the >100-schedule test whose absence let two review rounds pass over this: it fails against the previous cap and passes against this one. Ref: https://consensyssoftware.atlassian.net/browse/TAT-3903
Match the density of the neighbouring entries: one line naming the API and the venue it is implemented for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WrdyHr1ELeWgsUGUjRrcK5
1f5b814 to
bb1477f
Compare
geositta
left a comment
There was a problem hiding this comment.
This is a strong foundation: the shared socket, account-scoped lifecycle handling, reconnect work, and active-order retention are thoughtfully structured, and the snapshot/delta merge follows Hyperliquid's documented stream behavior. I’m requesting changes because the current public contract can leave consumers without current state, erase fill history, publish stale frames after teardown, and silently disable updates when establishment or restoration fails. The aggregated and retention behavior also differs from the advertised parity with getTwapOrders(). Addressing these points will make this a reliable polling replacement for Pro traders and a safer API for React Native consumers.
| callback, | ||
| normalizedAccountId, | ||
| ); | ||
| this.#twapOrderAdapters.set(normalizedAccountId, adapt); |
There was a problem hiding this comment.
Could we replay #cachedTwapOrders.get(normalizedAccountId) to each newly registered callback? Hyperliquid sends the snapshot once per underlying subscription. When a second consumer subscribes after that frame, #ensureTwapOrderSubscription returns because the socket already exists, and the new consumer receives no schedules until a future delta. A newly mounted or remounted Pro TWAP view therefore remains empty while another consumer keeps the shared socket alive. Please cover this with a test where the second subscriber registers after the initial snapshot and immediately receives the retained full set.
There was a problem hiding this comment.
Fixed in 4ff77bb. subscribeToTwapOrders now replays the retained set to a subscriber that joins an already-open socket, guarded on a non-empty cache so a first subscriber doesn't get a spurious empty snapshot. Covered by a test where the second subscriber registers after the initial snapshot and immediately receives the full set.
|
|
||
| const subscription = await subscriptionClient.userTwapHistory( | ||
| { user: userAddress }, | ||
| (data: UserTwapHistoryWsEvent) => { |
There was a problem hiding this comment.
Could we check startGeneration inside this listener before processing the event? A frame queued before unsubscribe or reconnect can currently mutate the replacement cache and publish stale orders to newly registered subscribers. The post-subscription generation check protects registration, but it does not isolate later listener invocations from an invalidated socket.
There was a problem hiding this comment.
Fixed in 4ff77bb. The listener now compares startGeneration against the account's current generation and returns early, so a frame queued before an unsubscribe or reconnect can neither mutate the replacement's cache nor reach its subscribers.
| const activeOrders = allOrders.filter( | ||
| (order) => order.status === 'active', | ||
| ); | ||
| const terminalOrders = allOrders |
There was a problem hiding this comment.
Could we align this retention behavior with the public contract? SubscribeTwapOrdersParams states that callbacks receive the same full set as getTwapOrders(), while this slice drops every terminal schedule after 100. A trader replacing polling with this stream therefore loses older history retained by the REST read. If bounded retention is required, please make the limit part of the documented contract and align getTwapOrders(), or expose snapshot/delta events and let consumers choose their retention policy.
There was a problem hiding this comment.
Addressed in 250e23b by making the limit part of the documented contract rather than changing the behaviour. SubscribeTwapOrdersParams now states that every active schedule is always delivered while terminal ones are bounded to the most recent 100, and points callers needing older history at getTwapOrders(). Unbounded retention on a long-lived stream was the alternative, and the cap exists because userTwapHistory is history — it would otherwise accumulate every schedule an account has ever run. Happy to switch to explicit snapshot/delta events if you'd prefer consumers own the policy.
| normalizedAccountId === 'default' | ||
| ? undefined | ||
| : (normalizedAccountId as CaipAccountId); | ||
| await this.#ensureTwapOrderSubscription(accountId, adapt).catch( |
There was a problem hiding this comment.
Could we schedule a per-account retry when restoration fails, following the market-data restoration path below? This code clears the dead subscription and swallows the replacement failure. If the transport remains connected afterward, no later reconnect occurs and the registered TWAP subscribers remain without a live socket.
There was a problem hiding this comment.
Fixed in 4ff77bb. Added #scheduleTwapRestoreRetry, mirroring #scheduleRestoreRetry: one deferred attempt per account, skipped while clearing or when one is already pending, and it no-ops if the adapter or subscribers are gone by the time it fires.
| for (const historyEntry of history) { | ||
| const order = this.#adaptTwapOrder({ | ||
| historyEntry, | ||
| sliceFills: [], |
There was a problem hiding this comment.
Could we preserve known fills or model this as a discriminated partial update? Emitting fills: [] makes “not included by this channel” indistinguishable from “this order has no fills” and contradicts the documented drop-in replacement for getTwapOrders(). A consumer replacing state from this callback loses its Pro fill history. Hyperliquid exposes userTwapSliceFills; alternatively, the service can retain prior fills while merging lifecycle deltas.
There was a problem hiding this comment.
Fixed in 4ff77bb, in the service rather than the provider. The merge now carries forward the fills a prior frame or REST read resolved, so an empty fills from this channel no longer overwrites known history. Joining userTwapSliceFills into the same channel is the fuller answer — it needs a second accumulated cache and a join on every event, so I've left it out of this PR deliberately rather than by oversight.
| (async (): Promise<void> => { | ||
| const userAddress = await userAddressPromise; | ||
| await this.#rebalanceTerminalHip3Twaps(orders, { | ||
| network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', |
There was a problem hiding this comment.
Could we capture network synchronously alongside userAddressPromise before starting the detached task? The account scope is intentionally captured before the async boundary, but the network scope is read afterward. A network toggle while address resolution is pending makes cleanup search the opposite network's tracking key, skip the terminal TWAP, and leave transferred collateral unrebalanced.
There was a problem hiding this comment.
Fixed in 4ff77bb. network is now captured synchronously alongside userAddressPromise, before the detached task starts, so a toggle mid-resolution can't key the reclaim to the opposite network.
| * @returns A cleanup function; a no-op when the default provider has no | ||
| * native TWAP push channel. | ||
| */ | ||
| subscribeToTwapOrders(params: SubscribeTwapOrdersParams): () => void { |
There was a problem hiding this comment.
Could we keep this subscription's provider scope consistent with getTwapOrders(), or expose the narrower scope explicitly in the API? getTwapOrders() returns every active provider, but this method subscribes only to the default provider. An AggregatedPerpsProvider configured with MYX or Lighter as default returns a no-op even when its Hyperliquid provider has live TWAP data. Please either subscribe to every capable provider and merge by providerId, or return an explicit supported/unsupported result with a provider route.
There was a problem hiding this comment.
Not changed, and I want to flag the reasoning rather than quietly decline. I implemented the fan-out (subscribe to every capable provider, merge by providerId) and then reverted it: MYX is being removed, and HyperLiquid is the only venue with a native TWAP push channel, so the merge would have had nothing to merge. The default-provider scope is now stated explicitly in the method doc. If a second push-capable venue lands, the fan-out is the right shape and I'm happy to add it then.
| */ | ||
| subscribeToTwapOrders(params: SubscribeTwapOrdersParams): () => void { | ||
| const provider = this.getActiveProviderOrNull(); | ||
| if (!provider?.subscribeToTwapOrders) { |
There was a problem hiding this comment.
Could we return an explicit establishment result, such as { status: 'subscribed', unsubscribe } | { status: 'unsupported' | 'not_ready' }, and expose asynchronous establishment failures? Returning the same no-op cleanup for an unavailable provider, an unsupported provider, and a setup failure is indistinguishable from a working subscription, so consumers cannot reliably continue polling as the contract requires.
There was a problem hiding this comment.
Not changed in this PR. I agree the three cases are indistinguishable, but a discriminated establishment result changes the shape of a public controller method, and every other subscribeTo* on this controller returns a bare () => void. Making TWAP the sole exception seemed worse than the ambiguity. If you'd like the richer result, I'd rather do it as a follow-up across all the subscription methods so the surface stays consistent — happy to open that.
| twapId, | ||
| }); | ||
|
|
||
| const statusAwareAdapt = (history: any) => |
There was a problem hiding this comment.
Please replace the new any adapter and event fixtures with TwapHistoryResponse and UserTwapHistoryWsEvent, using satisfies for fixture validation. These tests exercise the Hyperliquid boundary, but history: any and entry: any allow invalid venue fields and status shapes to compile, removing the type-level protection that the production adapter depends on.
There was a problem hiding this comment.
Fixed in 4ff77bb. The adapters are now typed (history: TwapHistoryResponse): TwapOrder[], the fixture builder uses satisfies TwapHistoryResponse[number], and the mock listeners take UserTwapHistoryWsEvent. This PR now adds zero any — verified by diffing : any occurrences against origin/main. You were right that this mattered: the untyped adapter is why a startedAt vs lastUpdated mix-up in the retention cap compiled cleanly and survived two review rounds.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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 bb1477f. Configure here.
- Replay the retained set to a subscriber that joins an already-open socket. The venue sends its snapshot once per socket, so a second consumer saw nothing until the next delta, which may never arrive. - Guard the listener itself against an invalidated generation, so a frame queued before an unsubscribe or reconnect cannot mutate the replacement's cache or reach its subscribers. - Carry known fills across stream merges. This channel omits slice fills, so an empty array meant 'not sent', not 'none', and a consumer replacing state lost its fill history. - Capture the network synchronously with the address before the detached HIP-3 reclaim, so a network toggle mid-resolution cannot key the cleanup to the opposite network. - Retry a failed TWAP restoration per account instead of swallowing it, which left registered subscribers with no socket and no later reconnect to recover them. - Type the test fixtures against TwapHistoryResponse and UserTwapHistoryWsEvent. The untyped adapter is why the startedAt versus lastUpdated confusion behind the eviction defect compiled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WrdyHr1ELeWgsUGUjRrcK5
The callback doc promised the same full set as getTwapOrders() while the stream bounds terminal schedules to the most recent 100. Say so, and point callers needing older history at the REST read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WrdyHr1ELeWgsUGUjRrcK5
|
Thanks @geositta — that was a genuinely useful review; several of these were real and one of them (the untyped fixtures) explains an earlier defect.
Deliberately not changed, reasoning in the threads:
Several Bugbot comments were snapshots of earlier commits (the global generation counter and the missing HIP-3 rebalance were both already fixed); noted individually. 🤖 Generated with Claude Code |
## Explanation Minor release of `@metamask/perps-controller` (`15.0.0` → `15.1.0`). ### `@metamask/perps-controller@15.1.0` #### Added - Provider-routed Scale price normalization through `PerpsController:getScalePriceLadder`, the optional `PerpsProvider.getScalePriceLadder` hook, and exported `DirectProviderScalePriceLadderUnavailableReason` / `ScalePriceLadderUnavailableReason` ([MetaMask#10021](MetaMask#10021), [MetaMask#10065](MetaMask#10065)) - Optional batch-level `error` on `ClosePositionsResult` ([MetaMask#10037](MetaMask#10037)) - `PerpsController.subscribeToTwapOrders` and the optional `PerpsProvider.subscribeToTwapOrders` hook for HyperLiquid TWAP streaming ([MetaMask#10056](MetaMask#10056)) #### Fixed - Prevent stale HyperLiquid positions from driving TP/SL, close, batch-close, margin-update, and HIP-3 margin calculations ([MetaMask#10037](MetaMask#10037)) - Floor TP/SL and reduce-only edit sizes to the venue size grid instead of rounding above the position ([MetaMask#10037](MetaMask#10037)) No breaking changes. No other packages are version-bumped. `main` is merged in so [MetaMask#10065](MetaMask#10065) is included. ## References - MetaMask#10021 - MetaMask#10037 - MetaMask#10056 - MetaMask#10065 ## Checklist - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Release metadata and version pins only; behavioral changes ship in the prior feature PRs referenced by the changelog, not in this diff. > > **Overview** > **Monorepo release 1228.0.0** that publishes **`@metamask/perps-controller@15.1.0`** (from `15.0.0`). The diff is version bumps plus changelog cutover only—no runtime code in this PR. > > The new **15.1.0** changelog section documents already-merged work: **Scale price ladder** via `getScalePriceLadder` / provider hook and unavailable-reason types; optional batch **`error`** on `ClosePositionsResult`; **TWAP order streaming** via `subscribeToTwapOrders` (HyperLiquid); fixes for **stale HyperLiquid position data** on TP/SL, close, batch-close, and margin paths; and **flooring** TP/SL and reduce-only edit sizes to the venue size grid. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a187389. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
…nd TWAP fill history (MetaMask#35544) ## **Description** Adds a TWAP tab beside Positions and Orders in the Pro market panel, so a running TWAP can be monitored and terminated instead of disappearing after its placement toast. Also fixes the Randomize label alignment in the TWAP order form ([TAT-3902](https://consensyssoftware.atlassian.net/browse/TAT-3902)). The predecessor PR (MetaMask#35332, TWAP placement) shipped with monitoring explicitly out of scope because Mobile lacked the controller contracts. `@metamask/perps-controller@15.1.0` now exposes both `getTwapOrders()` and `subscribeToTwapOrders()`. Active schedule state is pushed through the subscription, while current and terminal schedules with their slice fills are reconciled from the read API so Active, History, and Fill History share one source of truth. Two deliberate limits, both worth a reviewer's attention: - **Schedule state streams; fill history is reconciled by polling.** `subscribeToTwapOrders()` delivers live schedule changes. Because the subscription does not include slice-fill updates or unbounded terminal history, the selected TWAP tab also refreshes `getTwapOrders()` every 5s and immediately when live reconciliation starts or resumes and after termination. Rollout-off discovery uses a lower-cadence read while the tab is hidden. Streaming support from [core#10056](MetaMask/core#10056) is shipped in `@metamask/perps-controller@15.1.0`; it is no longer an optional yalc-only dependency. - **Trigger price and max price are not merely missing from the controller — the venue does not have them.** HyperLiquid's `TwapState` is `{ coin, executedNtl, executedSz, minutes, randomize, reduceOnly, side, sz, timestamp, user }`. There is no trigger or max price anywhere in its TWAP API, so this part of the ticket cannot be satisfied by any client or Core change and needs a spec correction. The TWAP side filter is component state rather than persisted: `ProLayoutPreferences` has `positionsSideFilter`/`ordersSideFilter` but no TWAP field, and adding one is a Core change. This keeps the PR Mobile-only. ## **Follow-up: [TAT-3913](https://consensyssoftware.atlassian.net/browse/TAT-3913) — aggregated-provider order inputs** Aggregated multi-provider ordinary-order placement is outside this PR's scope. A code audit confirmed that same-symbol order writes can carry `market.providerId`, while `usePerpsMarketData`, `useHasExistingPosition`, `usePerpsLivePrices`, and `usePerpsTopOfBook` still resolve inputs by symbol. The shared price and top-of-book caches are also symbol-keyed, so fully supporting symbol collisions requires coordinated provider-qualified read/subscription changes in those hooks and `PerpsStreamManager`, with collision coverage. Until that follow-up lands, **All Providers** is not a supported order-entry route for same-symbol markets; this PR's order-entry validation covers a concrete selected provider. TWAP management remains provider-qualified for row identity, navigation, and termination. ## **Changelog** CHANGELOG entry: Added a TWAP tab to the Perps Pro market panel for monitoring and terminating TWAP orders ## **Related issues** Fixes: [TAT-3903](https://consensyssoftware.atlassian.net/browse/TAT-3903) — TWAP management tab Fixes: [TAT-3902](https://consensyssoftware.atlassian.net/browse/TAT-3902) — Randomize label alignment Follows: [metamask-mobile#35332](MetaMask#35332) — TWAP order placement Delivered by: [core#10056](MetaMask/core#10056) — `subscribeToTwapOrders()` in `@metamask/perps-controller@15.1.0` ## **Manual testing steps** 1. Enable the `perps-mobile-twap` flag and switch Perps to Pro mode 2. Open any market (e.g. BTC) and scroll to the Positions/Orders panel 3. Confirm a third **TWAP** tab sits beside Positions and Orders 4. Select it — the Active / History / Fill History switch appears, and the `All sides` and `<ticker> only` filters stay available 5. Place a TWAP from the Pro order form; it appears under Active with size, filled size, average price, progress, and elapsed/total 6. Press **Terminate** — a confirmation sheet states the remaining size will not execute and filled size stays as a position. Confirm, and the schedule moves to History 7. Check Fill history lists the individual executed slices 8. In the TWAP order form, confirm **Randomize** is left-aligned with **Runtime** above it ([TAT-3902](https://consensyssoftware.atlassian.net/browse/TAT-3902)) ## **Screenshots/Recordings** No standalone visual evidence selected. The recipe passed its TWAP tab, subview, and filter selector assertions, but the captured PNG frames the upper order form rather than the claimed management panel. ## **Validation Recipe** <details><summary>recipe.json (13 nodes — asserts the TWAP tab, its three views, and the shared filters)</summary> ```json { "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", "title": "TAT-3903 Pro TWAP management tab proof", "description": "Proves the TWAP tab renders beside Positions and Orders in the Pro panel, carries the shared ticker-only and side filters, exposes the Active / History / Fill History views, and labels itself plain TWAP when no schedule is active.", "paramsSchema": { "type": "object", "additionalProperties": false, "properties": {} }, "proofTargets": [ { "id": "PT-1", "claim": "A third tab labelled TWAP renders beside Positions and Orders in the Pro panel and can be selected." }, { "id": "PT-2", "claim": "The TWAP tab exposes three selectable views \u2014 Active, History, and Fill History." }, { "id": "PT-3", "claim": "The TWAP tab carries the same side and ticker-only filters as the Positions and Orders tabs, and reads plain TWAP with no count while no schedule is active." } ], "workflow": { "entry": "prepare-perps", "teardown": "restore-perps", "nodes": { "prepare-perps": { "action": "metamask.perps.start_state", "profile": "clean_market_testnet", "provider": "hyperliquid", "network": "testnet", "market": "BTC", "page": "market", "positions": { "state": "none", "mode": "matching" }, "orders": { "state": "none", "mode": "matching" }, "timeout_ms": 120000, "intent": "Prepare an unlocked HyperLiquid testnet BTC Pro market with no pre-existing BTC positions or orders.", "next": "wait-panel-tabs" }, "wait-panel-tabs": { "action": "ui.wait_for", "test_id": "perps-pro-market-positions-panel-tabs", "expected": "present", "timeout_ms": 20000, "intent": "Confirm the Pro positions/orders panel tab bar is mounted before asserting the new tab.", "next": "wait-twap-tab" }, "wait-twap-tab": { "action": "ui.wait_for", "test_id": "perps-pro-market-positions-panel-tab-twap", "expected": "present", "timeout_ms": 20000, "intent": "Confirm the user is offered a TWAP tab next to Positions and Orders, which is the surface this ticket adds for monitoring running schedules.", "proves": [ "PT-1" ], "next": "open-twap-tab" }, "open-twap-tab": { "action": "ui.press", "test_id": "perps-pro-market-positions-panel-tab-twap", "intent": "Select the TWAP tab so its own view switcher and filters render.", "next": "wait-view-active" }, "wait-view-active": { "action": "ui.wait_for", "test_id": "perps-pro-market-twap-view-tab-active", "expected": "present", "timeout_ms": 15000, "intent": "Assert the Active view is offered inside the TWAP tab.", "proves": [ "PT-2" ], "next": "wait-view-history" }, "wait-view-history": { "action": "ui.wait_for", "test_id": "perps-pro-market-twap-view-tab-history", "expected": "present", "timeout_ms": 15000, "intent": "Assert the History view is offered inside the TWAP tab.", "proves": [ "PT-2" ], "next": "wait-view-fill-history" }, "wait-view-fill-history": { "action": "ui.wait_for", "test_id": "perps-pro-market-twap-view-tab-fill-history", "expected": "present", "timeout_ms": 15000, "intent": "Assert the Fill History view is offered inside the TWAP tab.", "proves": [ "PT-2" ], "next": "wait-side-filter" }, "wait-side-filter": { "action": "ui.wait_for", "test_id": "perps-pro-market-positions-side-filter-button", "expected": "present", "timeout_ms": 15000, "intent": "Assert the shared side filter stays available while the TWAP tab is selected.", "proves": [ "PT-3" ], "next": "wait-ticker-only" }, "wait-ticker-only": { "action": "ui.wait_for", "test_id": "perps-pro-market-positions-ticker-only", "expected": "present", "timeout_ms": 15000, "intent": "Assert the shared ticker-only filter stays available while the TWAP tab is selected.", "proves": [ "PT-3" ], "next": "capture-twap-tab" }, "capture-twap-tab": { "action": "ui.screenshot", "label": "TWAP tab selected beside Positions and Orders, showing the Active / History / Fill History switch, the shared All sides and BTC only filters, and the plain TWAP label with no count while no schedule is active.", "intent": "Preserve the visible TWAP tab state for review.", "proves": [ "PT-1", "PT-3" ], "next": "entry-done" }, "entry-done": { "action": "end", "status": "pass" }, "restore-perps": { "action": "metamask.perps.teardown_state", "market": "BTC", "orders": { "state": "none", "mode": "matching" }, "positions": { "state": "none", "mode": "matching" }, "page": "home", "timeout_ms": 60000, "intent": "Return to Home and clear any BTC order or position left by the proof window.", "next": "teardown-done" }, "teardown-done": { "action": "end", "status": "pass" } } } } ``` </details> ## **Validation Logs** Command: ```bash --adapter mobile \ --target /Users/deeeed/dev/metamask/metamask-mobile-3 \ --slot macpro-mm-3 ``` <details><summary>Full output (13/13 passed)</summary> ``` PASS recipe run [mobile] summary: PASS prepare-perps (metamask.perps.start_state, 2.6s): proof=metamask-perps-start-state PASS wait-panel-tabs (ui.wait_for, 437ms): matched=true, testId=perps-pro-market-positions-panel-tabs, present=true, visible=true PASS wait-twap-tab (ui.wait_for, 441ms): matched=true, testId=perps-pro-market-positions-panel-tab-twap, present=true, visible=true PASS open-twap-tab (ui.press, 449ms): ok=true, testId=perps-pro-market-positions-panel-tab-twap, deviceName=mm-3 PASS wait-view-active (ui.wait_for, 451ms): matched=true, testId=perps-pro-market-twap-view-tab-active, present=true, visible=true PASS wait-view-history (ui.wait_for): matched=true, testId=perps-pro-market-twap-view-tab-history, present=true, visible=true PASS wait-view-fill-history (ui.wait_for): matched=true, testId=perps-pro-market-twap-view-tab-fill-history, present=true, visible=true PASS wait-side-filter (ui.wait_for): matched=true, testId=perps-pro-market-positions-side-filter-button, present=true, visible=true PASS wait-ticker-only (ui.wait_for): matched=true, testId=perps-pro-market-positions-ticker-only, present=true, visible=true PASS entry-done (end) PASS restore-perps (metamask.perps.teardown_state) PASS teardown-done (end) status: pass | passed: 13 / 13 | failed: 0 ``` </details> Removal self-test: every test ID the recipe asserts (`perps-pro-market-positions-panel-tab-twap`, `perps-pro-market-twap-view-tab-*`) exists only in this branch — `git grep` on `origin/main` returns nothing, so the recipe fails without this PR. The committed recipe covers the structural claims (tab, three views, shared filters) against a clean account. The placement → populated card → terminate → status-transition sequence was driven node-by-node through the same engine (`mm-harness call`) rather than folded into the recipe, because it mutates live venue state and leaves a residual position. Whether that belongs in the repeatable recipe is worth settling before this leaves draft. Unit tests, run after the recipe passed: ``` proTwapViews.test.ts 8/8 passed twapFormat.test.ts 9/9 passed proPositionSideFilter.test.ts 13/13 passed (9 pre-existing + 4 new) usePerpsToasts.test.tsx 72/72 passed (no regression from the formatTwapDuration extraction) PerpsProPositionsPanel.test.tsx 37/37 passed (no regression in the modified panel) ``` ESLint clean on all changed files. No type errors in any touched file. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [TAT-3902]: https://consensyssoftware.atlassian.net/browse/TAT-3902?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [TAT-3903]: https://consensyssoftware.atlassian.net/browse/TAT-3903?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches live TWAP cancellation, order placement routing by provider, and multi-venue market navigation—high user impact but heavily covered by tests. > > **Overview** > Adds a **TWAP** tab to the Pro market positions panel so users can monitor active schedules, browse history and fill history, filter by side/ticker, and **terminate** TWAPs (with confirmation, rollout-off discovery, retry, and guards against double-cancel while reconciliation fails). New UI pieces include TWAP cards, fill rows, empty states, terminate sheet wiring via `usePerpsProTwapManagement`, and a large set of **test IDs** and automated tests (unit + component-view journeys). > > **Pro market identity is now symbol + provider:** route enrichment, analytics `resetKey`, order-form remount keys, and row taps from positions/orders/TWAP switch venue when the same symbol exists on another provider. The Pro order form passes **`market.providerId`** through fees, validation, preview, and placement for ordinary order types (not only TWAP/Chase/Scale). > > Smaller UX fix: **Randomize** in `PerpsProTwapFields` uses an element label + `accessibilityLabel` so it aligns with Runtime. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 49630f5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [TAT-3913]: https://consensyssoftware.atlassian.net/browse/TAT-3913?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: javiergarciavera <76975121+javiergarciavera@users.noreply.github.com>

Explanation
TWAP was read-only through
getTwapOrders(), so a client wanting to monitor a running schedule had to poll — and each poll is two venue REST calls (twapHistory+userTwapSliceFills). HyperLiquid already pushes the sametwapHistorypayload over itsuserTwapHistoryWebSocket channel; nothing in Core consumed it.This adds
subscribeToTwapOrdersso clients can stream instead.Found while building the Mobile TWAP management tab (metamask-mobile#35544), which currently polls at 5s purely because this contract was missing.
Design notes
SubscribeTwapOrdersParamsdelivers the sameTwapOrder[]shapegetTwapOrders()returns, so a client swaps a poll for a subscription without reshaping state.getTwapOrders?.#adaptTwapOrderand its helpers are private toHyperLiquidProvider, so rather than move venue-shape knowledge into the subscription service (or duplicate it), the provider passes anadaptcallback.getTwapOrders()is untouched.#ensureOpenOrdersSubscription.clearAll()tears the channel down.Known limitation
The venue streams schedule state without slice fills, so pushed schedules carry an empty
fillsarray. The Mobile consumer carries forward the fills a priorgetTwapOrders()read supplied rather than blanking its Fill History view. JoininguserTwapSliceFillsinto the same channel is possible (the SDK exposes it) but needs two accumulated caches and a join on every event — deliberately left out of this PR.Separately: HyperLiquid's
TwapStatecarries no trigger price or max price ({ coin, executedNtl, executedSz, minutes, randomize, reduceOnly, side, sz, timestamp, user }). Those fields cannot be surfaced by any client or Core change.References
Ref: https://consensyssoftware.atlassian.net/browse/TAT-3903
Mobile consumer: MetaMask/metamask-mobile#35544
Checklist
Note
Medium Risk
New live trading data path with async HIP-3 rebalance on terminal TWAPs and complex reconnect/generation handling; mistakes could strand collateral or deliver stale merged state, though behavior mirrors existing poll paths.
Overview
Adds
subscribeToTwapOrdersso clients can stream TWAP schedule updates instead of pollinggetTwapOrders(). The hook is optional onPerpsProvider; when missing, the controller returns a no-op unsubscribe so callers can fall back to polling without provider checks.HyperLiquid wires the venue
userTwapHistoryWebSocket throughHyperLiquidSubscriptionService(per-account multiplexing, snapshot/delta merge, cached replay for late subscribers, reconnect restore, and a cap of 100 terminal schedules while keeping all active ones). Pushed schedules omit slice fills; the service preserves fills from prior frames/REST when the stream sends emptyfills. The provider adapts history toTwapOrder[]and runs the same detached HIP-3 collateral rebalance on terminal schedules as polled reads, with address/network captured before async work to avoid account-switch races.AggregatedPerpsProviderdelegates only to the default provider (no multi-provider mux) and stampsproviderIdon callbacks likegetTwapOrders. Public exports includeSubscribeTwapOrdersParamsand the controller action type; tests cover controller no-ops and subscription lifecycle.Reviewed by Cursor Bugbot for commit 250e23b. Bugbot is set up for automated code reviews on this repo. Configure here.