Skip to content

feat(perps-controller): add subscribeToTwapOrders - #10056

Merged
abretonc7s merged 8 commits into
mainfrom
TAT-3903-feat-twap-subscription
Sep 2, 2026
Merged

abretonc7s merged 8 commits into
mainfrom
TAT-3903-feat-twap-subscription

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 same twapHistory payload over its userTwapHistory WebSocket channel; nothing in Core consumed it.

This adds subscribeToTwapOrders so 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

  • SubscribeTwapOrdersParams delivers the same TwapOrder[] shape getTwapOrders() returns, so a client swaps a poll for a subscription without reshaping state.
  • The provider member is optional. Providers without a native TWAP push channel omit it and the controller returns a no-op cleanup, so callers need not branch on provider identity and Lighter/MYX need no stubs. This also keeps the change non-breaking. It follows the precedent already set by getTwapOrders?.
  • The service owns transport and fan-out only; the provider supplies the adapter. #adaptTwapOrder and its helpers are private to HyperLiquidProvider, so rather than move venue-shape knowledge into the subscription service (or duplicate it), the provider passes an adapt callback. getTwapOrders() is untouched.
  • Concurrent subscribers share one socket. The first implementation opened a second WebSocket when two subscribers registered before either finished awaiting; a unit test caught it, and it now uses the same promise-dedup pattern as #ensureOpenOrdersSubscription.
  • Reconnect is handled. Adapters are retained per account so the subscription is re-established after a reconnect, alongside the existing order-fill rehydration. clearAll() tears the channel down.

Known limitation

The venue streams schedule state without slice fills, so pushed schedules carry an empty fills array. The Mobile consumer carries forward the fills a prior getTwapOrders() read supplied rather than blanking its Fill History view. Joining userTwapSliceFills into 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 TwapState carries 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

  • 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 highlighted breaking changes using the "BREAKING" category above as appropriate
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

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 subscribeToTwapOrders so clients can stream TWAP schedule updates instead of polling getTwapOrders(). The hook is optional on PerpsProvider; when missing, the controller returns a no-op unsubscribe so callers can fall back to polling without provider checks.

HyperLiquid wires the venue userTwapHistory WebSocket through HyperLiquidSubscriptionService (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 empty fills. The provider adapts history to TwapOrder[] 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.

AggregatedPerpsProvider delegates only to the default provider (no multi-provider mux) and stamps providerId on callbacks like getTwapOrders. Public exports include SubscribeTwapOrdersParams and 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.

@abretonc7s
abretonc7s marked this pull request as ready for review September 1, 2026 18:20
@abretonc7s
abretonc7s requested review from a team as code owners September 1, 2026 18:20
Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
@cursor
cursor Bot requested review from Mrtenz and mcmire September 1, 2026 18:27
Comment thread packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts Outdated
abretonc7s and others added 6 commits September 2, 2026 10:38
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
@abretonc7s
abretonc7s force-pushed the TAT-3903-feat-twap-subscription branch from 1f5b814 to bb1477f Compare September 2, 2026 02:40

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

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);

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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) => {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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(

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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: [],

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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',

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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) {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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) =>

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@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 2 potential issues.

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 bb1477f. Configure here.

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
Bigshmow
Bigshmow previously approved these changes Sep 2, 2026
- 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
@abretonc7s

Copy link
Copy Markdown
Contributor Author

Thanks @geositta — that was a genuinely useful review; several of these were real and one of them (the untyped fixtures) explains an earlier defect.

Fixed in 4ff77bb / 250e23b:

  • Cached replay for a subscriber joining an already-open socket
  • Listener-level generation guard, so a frame queued before unsubscribe/reconnect can't mutate the replacement cache
  • Fills carried across stream merges, so an empty fills no longer overwrites known history
  • network captured synchronously with the address before the detached HIP-3 reclaim
  • Per-account retry on failed TWAP restoration instead of swallowing it
  • Test fixtures typed against TwapHistoryResponse / UserTwapHistoryWsEvent; this PR now adds zero any
  • Retention bound is now part of the documented contract

Deliberately not changed, reasoning in the threads:

  • Multi-provider fan-out — I built it, then reverted: MYX is being removed and HyperLiquid is the only push-capable venue, so there'd be nothing to merge
  • Discriminated establishment result — worth doing, but across all subscribeTo* methods rather than making TWAP the lone exception

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

https://claude.ai/code/session_01WrdyHr1ELeWgsUGUjRrcK5

@abretonc7s
abretonc7s added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 99612d5 Sep 2, 2026
47 checks passed
@abretonc7s
abretonc7s deleted the TAT-3903-feat-twap-subscription branch September 2, 2026 03:13
@abretonc7s abretonc7s mentioned this pull request Sep 2, 2026
2 tasks
pull Bot pushed a commit to Reality2byte/core that referenced this pull request Sep 2, 2026
## 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 -->
pull Bot pushed a commit to firas9941/metamask-mobile that referenced this pull request Sep 3, 2026
…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>
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.

3 participants