fix(perps): HL Unified-mode live balance — spotState ws + tradeable-balance + total-balance math cp-7.72.2#29226
Conversation
…eferences Brings the in-repo HL reference set up to date with hyperliquid.gitbook.io as of 2026-04-23: - account-abstraction-modes.md: Standard / Unified / Portfolio Margin / DEX abstraction definitions + the computeUnifiedAccountRatio reference implementation. - portfolio-margin.md: LTV-based buying-power math, alpha caps, liquidation ratio formula. - margin-tiers.md: mainnet + testnet tiered leverage tables. - margining.md: HIP-3 cross-margin behaviour per abstraction mode, "strict isolated" rename, minor formatting alignment. - subscriptions.md: spotState, allDexsClearinghouseState, allDexsAssetCtxs subscription types + WsSpotState / SpotState / UserBalance data formats, per-DEX filter on twapStates / clearinghouseState / openOrders. Adds docs/perps/tat-3016-clarity.md: one-page incident brief and architectural critique for the HL spot-balance incident (TAT-3016), including the rationale for switching from REST-pull refresh to the spotState WebSocket subscription.
…lance Problem ------- HyperLiquid's spot clearinghouse state (per-coin total + hold) changes on every on-chain event — local trades, external-client trades, hourly funding, liquidations, deposits, transfers, usdClassTransfer — but none of those are surfaced through webData2/webData3/clearinghouseState. The existing REST path (#refreshSpotState -> infoClient.spotClearinghouseState) only runs on cold-start or standalone getAccountState calls, so the cached spot snapshot goes stale the moment anything moves it. UI surfaces that read availableToTradeBalance or totalBalance see stale values until the next account-state screen refresh. Fix --- Subscribe to HL's existing `spotState` WebSocket feed alongside the webData2/webData3 user-data subscriptions already established in subscribeToAccount. The push handler: - updates #cachedSpotState + #cachedSpotStateUserAddress with the pushed SpotClearinghouseStateResponse - bumps #spotStateGeneration so any in-flight REST #refreshSpotState drops its result rather than overwriting the fresh WS snapshot - re-runs #aggregateAndNotifySubscribers when #dexAccountCache is populated, so subscribers see the spot-folded totalBalance / availableToTradeBalance immediately REST fallback is left in place: #refreshSpotState still covers cold start before the first WS tick and the standalone getAccountState path that does not own a subscription. Shape ----- - #spotStateSubscriptions: Map<userAddress, ISubscription> — one sub per user, mirrors the per-user lifecycle of #orderFillSubscriptions. - #spotStateSubscriptionPromises: Map<userAddress, Promise<void>> — in-flight guard so two concurrent subscribeToAccount calls share a single WS handshake. - Cleanup in both #cleanupSharedWebData3ISubscription (ref-counted) and clearAll (shutdown). Test coverage ------------- Five new tests in the "spotState WebSocket Subscription" block cover: establishment on subscribeToAccount, de-duplication across concurrent subscribes, re-notification on push, user-filter guarding cross-account events, and unsubscribe on last-subscriber removal. Notes ----- No change to #ensureSpotState, #refreshSpotState, #spotStateGeneration, or #cachedSpotState semantics — those remain authoritative for the REST-driven cold-start and standalone paths. This commit adds a push source for the same cache without touching the REST writers.
Adds a public `getExchangeClient()` method on HyperLiquidProvider that delegates to HyperLiquidClientService.getExchangeClient(). Not part of the PerpsProvider interface — documented as an escape hatch for agentic validation flows and test harnesses that need to drive HL mutations directly (order, usdClassTransfer, userSetAbstraction, etc.) while bypassing TradingService, metrics, error-handler wrappers, and cache invalidation. Needed by the upcoming tat-3016 regression recipe to probe that a mutation originating outside TradingService still propagates through the spotState WebSocket subscription added in the previous commit. Production code paths continue to go through the provider's own methods. Tests cover delegation and error propagation.
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
Validates that the spotState WebSocket subscription added earlier in this branch keeps AccountState.availableToTradeBalance + totalBalance fresh without any manual refresh, across three mutation classes: 1. UI-driven open — trade-open-market flow places a $11 BTC long. Recipe waits on marginUsed > 0 from the pushed state, then re-runs hl-balance-validation to assert the fold invariant + UI parity. 2. UI-driven close — trade-close-position mirror. 3. External-client — drives provider.getExchangeClient().usdClassTransfer directly, bypassing TradingService entirely. Proves the subscription catches mutations that never touched any of the TradingService refresh sites closed-#29217 tried to hook. Composes the existing hl-balance-validation + trade-open-market + trade-close-position flows. Reuses the getExchangeClient escape hatch added by the preceding commit. Restores the USDC moved by the external probe at end of run so the fixture account finishes clean. Mainnet-only: the external probe relies on usdClassTransfer which is an abstraction-aware mutation. On testnet the recipe will run through the UI-driven portion but skip the external-probe branch cleanly. Schema: passes node scripts/perps/agentic/validate-flow-schema.js. Run: bash scripts/perps/agentic/validate-recipe.sh evidence/
… debug markers Problem caught during recipe validation --------------------------------------- Running evidence/recipe.json on main-with-spotState-subscription showed that `totalBalance` updated correctly via the push, but `availableToTradeBalance` stayed at 0 / undefined across every snapshot. Root cause: the field declared by the order-entry story was never actually populated in the data pipeline. `addSpotBalanceToAccountState` bumped `totalBalance` only; `adaptAccountStateFromSDK` and `aggregateAccountStates` did not touch the field at all. All UI surfaces reading the fallback `availableToTradeBalance ?? availableBalance` therefore reverted to the HL `withdrawable` value, which is 0 on Unified/spot-only accounts. Fix --- - `AccountState.availableToTradeBalance?: string` is now a first-class field in the contract (still optional so MYX / providers that don't need it can omit). - `adaptAccountStateFromSDK(perps, spot?)` computes it inline: `withdrawable + max(0, spot.total - spot.hold)` across the SPOT_COLLATERAL_COINS allowlist. - `getSpotHold()` helper mirrors `getSpotBalance()` for the hold side. - `addSpotBalanceToAccountState()` now writes `availableToTradeBalance = availableBalance + freeSpot` and keeps the existing `totalBalance += spotBalance` fold. When no spot is held it still sets the field to the current withdrawable so consumers always see a concrete value. - `aggregateAccountStates()` sums `availableToTradeBalance` across per-DEX states when both sides have it defined; falls back to `availableBalance` otherwise. UI surface ---------- - `PerpsMarketBalanceActions.tsx` (renders `perps-market-available-balance-text`) now reads `perpsAccount?.availableToTradeBalance ?? perpsAccount?.availableBalance`. Withdraw surfaces deliberately untouched so `PerpsWithdrawView` continues to render the raw `availableBalance`. Instrumentation --------------- Adds DevLogger markers under the `[TAT-3016]` prefix so the data flow is greppable in Metro logs: - `HyperLiquidSubscriptionService` spotState handler logs pushed balance/hold, user filter rejections, and the "deferred because no perps data cached yet" branch. - `#aggregateAndNotifySubscribers` logs the post-aggregation AccountState shape. - `addSpotBalanceToAccountState` logs inputs and derived `freeSpot`. All markers are temporary diagnostics for incident validation and will be removed in a follow-up commit once the recipe is green.
…ance-validation recipe Withdraw-path non-regression anchor for the hl-balance-validation flow on main. The agentic flow asserts that the text rendered at this testID matches AccountState.availableBalance (withdrawable) and never leaks the availableToTradeBalance spot fold — the invariant TAT-3016 hinges on. Previously the flow's settle-withdraw wait_for timed out because the testID was only referenced in flow JSON, not rendered by the view.
Spot-funded Unified-mode accounts were auto-steered onto external USDC pay-token because useDefaultPayWithTokenWhenNoPerpsBalance gated on raw availableBalance (= withdrawable = 0 on Unified). With the field now populated end-to-end, switch the gate to availableToTradeBalance ?? availableBalance so the order form defaults to Perps balance when spot collateral is available. Caught by evidence/recipe.json: capture-order-form step showed paySymbol USDC instead of Perps on the Trading fixture.
PerpsMarketDetailsView.hasDirectOrderFundingPath only checked raw availableBalance (= withdrawable = 0 on Unified-mode spot-funded) plus the external pay-token fallback. After the pay-token hook learned to skip the external steer when availableToTradeBalance is non-zero, the gate flipped false and the Long/Short buttons disappeared, leaving Add-Funds as the only CTA on Unified-mode accounts with spot collateral. Add tradeableBalance (availableToTradeBalance ?? availableBalance) as the primary direct-funding signal. Original availableBalance check kept for provider/mode compatibility.
… TAT-3050 state Recipe adjustments after first run: - Reset leaked selectedPaymentToken + pending BTC trade config as the first node so baseline starts from a clean state (TAT-3050 sidestep). - Focus the graph on the hl-balance-validation pass which already covers: fold invariant, market-list balance text, withdraw non-regression (fold must NOT leak), and Pay-with row defaulting to Perps balance when availableToTradeBalance > 0. - External-client usdClassTransfer probe + UI-driven trade-open-market cycle deferred to a follow-up recipe — usdClassTransfer is abstraction- sensitive on Unified accounts and trade-open-market has a pre-existing gap on the RedesignedConfirmations route. Scope kept to what the current PR affects. Pre-conditions: - perps.sufficient_balance now gates on availableToTradeBalance (fallback: availableBalance). Previously read raw availableBalance which is 0 on HL Unified mode even when spot collateral covers orders — recipe pre-condition failed on accounts the product considers tradeable.
PerpsOrderView showed 'Insufficient balance. Required: $3.37, Available: $0' on Unified-mode accounts with spot-only collateral because usePerpsOrderForm fed availableBalance (= withdrawable = 0) into balanceForValidation, maxPossibleAmount, and initialBalancePercent. With the field now populated, prefer availableToTradeBalance when no external pay-token is selected. Consumer surfaces fixed in this commit: - perps-order-view slider/keypad max amount - perps-order-view percentage buttons - perps-order-view margin-vs-required gate - 'Insufficient funds' red text at the bottom of the form - Place Order button enablement
…on Unified mode Symptom ------- On HyperLiquid Unified/Portfolio Margin accounts opening a position caused totalBalance to increase by the position margin, not by the unrealized PnL. Example: $106 spot, 0 perps, no position → total $106. Open a $11 long (~$3.37 margin): spot stays $106, spot.hold becomes $3.37, perps accountValue becomes $3.37 — previous formula `perpsBalance + spotBalance` reported $109.37. No wealth changed hands but the displayed total went up. Root cause ---------- marginSummary.accountValue on Unified/PM equals marginUsed + unrealizedPnl + withdrawable_perps, where marginUsed is the margin reserved from spot (reported by HL as spot.hold). Adding accountValue to spotBalance double-counts that reservation. Fix --- Subtract spotHold from the totalBalance sum in both the single-DEX adapter (adaptAccountStateFromSDK) and the aggregated fold (addSpotBalanceToAccountState): totalBalance = perpsBalance + spotBalance - spotHold On Standard mode spotHold = 0, so the formula reduces to the previous `perpsBalance + spotBalance` and no behaviour changes. On Unified/PM the subtraction cancels the held-margin double-count, so totalBalance now moves only with unrealizedPnl — matching HL's own "Perp Equity" semantics. availableToTradeBalance is unaffected: it already computes `availableBalance + (spotTotal - spotHold)` which correctly nets the reservation out of the tradeable pool.
Review-prep cleanup pass: - Remove [TAT-3016] DevLogger diagnostics added for in-flight debugging. - Drop verbose block comments on the pay-token / order-form / market-details changes where the code is self-describing via identifier names. - Collapse the market-details direct-funding gate to a single availableToTradeBalance check (with fallback to availableBalance). - Drop unused DevLogger import in accountUtils. - Keep only the non-obvious WHY comments: double-count subtraction rationale, sentinel preservation, and the withdraw-testID constraint. - Rename the regression recipe off the Jira ticket reference.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ 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 9619d0c. Configure here.
Close the spotState subscribe-vs-cleanup race (bugbot #3128164456). #spotStateSubscriptionPromises was not cleared on cleanup, so an in-flight #ensureSpotStateSubscription continuation could resolve after clearAll/cleanupSharedWebData3 and rehydrate #spotStateSubscriptions with a now-orphaned subscription. The next reconnect then short-circuited on the stale entry, leaving spot balance updates silent for the session. Adds #spotStateSubscriptionGeneration bumped at both cleanup sites; the async body captures the generation before subscribing and unsubscribes + drops the fresh sub if the generation has moved.
…recipe HyperLiquidProvider.getExchangeClient() now returns Promise<ExchangeClient> to match the shape hl-provision-fixture already assumes (it chains .then on the return value). The underlying client-service call stays sync; the wrapper is async so callers await a single pattern. Recipe overhauled to cycle abstraction modes: - setup captures initial state + resets payment-token - workflow: set Standard → validate + parity → set Unified → validate + parity + order-form wiring → manual gate - teardown restores Unified Runs on both testnet and mainnet.
…ardown cleanup
Scenario from a reviewer report: open limit orders, 'cancel all',
balance stuck in pre-cancel state. Adds limit-cycle block that:
- seeds baseline (tradeable ≈ total)
- places a BTC limit at 50% of mid (won't fill)
- asserts tradeable drops below total (hold reserved)
- calls cancelOrders({cancelAll:true})
- asserts tradeable returns within $0.20 of total (hold released)
Recipe now also pre/post cancels any leftover orders so abstraction
mode switches don't fail on 'cannot disable unified account with open
orders'.
gambinish
left a comment
There was a problem hiding this comment.
I've tested this and I think this is definitely the approach we need. Thanks for figuring this out 👍
A couple of requests:
-
Please do some validation on Limit Orders, I noticed that the available balance doesn't always account for newly created limit orders or newly cancelled limit orders. I was testing single cancellations and also "cancel all" flow.
-
For the cherry pick, please exclude the files in the following directories:
docs/perps,evidenceandscripts/perps/agentic. These are valuable, but should be introduced intomainseparately, rather than as a cherry pick to an OTA update (per release guidelines)
…ance + populated availableToTradeBalance Five existing tests asserted pre-fix behavior: - HyperLiquidProvider.test getAccountState expected 20500 (perps 10500 + spot.total 10000, double-counting spot.hold 1000). New correct value is 19500 after the fix subtracts spot.hold from the sum. - Same for the HIP-3 partial-account-state test. - accountUtils.test 'ignores non-collateral', 'excludes USDH-only', and the zero-spot path all compared with .toBe(accountState) (identity). Since addSpotBalanceToAccountState now always populates availableToTradeBalance (defaulting to availableBalance when no collateral is held) the return is a new object; asserting field equivalence instead.
Applies fs-recipe-quality review: - Screenshots at each validation milestone so a cold reviewer can eyeball the evidence without re-running (phase-standard-summary, unified-order-form, limit-cycle-0-baseline / 1-hold-applied / 2-hold-released). - Teardown now asserts ok:true (was silently swallowing errors). - Setup + teardown both close any leftover positions before mode flips, not just orders. Abstraction switch rejects if positions OR orders OR TWAPs are open; previous runs were getting blocked on stale positions. - Manual-vs-hl-web gate removed: the runner skips it silently under --skip-manual anyway; replaced with unconditional screenshots that live in the artifact folder for async reviewer comparison.
Removing three files that will be shared/reviewed separately, not as part of this spotState subscription PR: - docs/perps/tat-3016-clarity.md — incident brief + architectural rationale. Lives better as a team doc than in the PR diff. - evidence/recipe.json — regression recipe. Will be committed through a separate agentic-validation track. - scripts/perps/agentic/teams/perps/pre-conditions.js — reverted to main. The availableToTradeBalance fallback in perps.sufficient_balance was needed to run the recipe; now that the recipe is out of scope, leave main's version untouched. This PR is now strictly: HL spotState subscription wiring, AccountState.availableToTradeBalance population, UI consumer rewiring, and supporting unit tests.
…DK output The hyperLiquidAdapter test file lives under app/components/UI/Perps/utils but imports the real adapter at app/controllers/perps/utils. Three account-state tests used toEqual with a fixed shape and failed on CI once the adapter started returning availableToTradeBalance. Added the expected values: withdrawable + free spot for each scenario. totalBalance is unchanged because all three scenarios have spot.hold = 0 so the subtraction is a no-op.
AI PR Analysis🚫 Merge safe: false | 🟠 Risk: high
AI analysis did not complete. Manual review recommended. |
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
SmokePerps: Directly tests Perps Add Funds flow, balance verification, and balance updates - all directly impacted by these changes. SmokeWalletPlatform: Per tag description, Perps is a section inside the Trending tab; changes to Perps views (PerpsMarketDetailsView, PerpsWithdrawView, PerpsMarketBalanceActions) affect Trending. SmokeConfirmations: Per SmokePerps tag description, "Add Funds deposits are on-chain transactions" - required when selecting SmokePerps. Documentation files and unit test files don't require additional E2E coverage beyond what's already selected. Performance Test Selection: |
|
✅ E2E Fixture Validation — Schema is up to date |
|




Description
TAT-3016 follow-up covering the remaining HL balance gaps after the initial spot-balance parity work.
What's broken on main
AccountState.totalBalanceis never updated live after a limit order is placed or cancelled — the HL spot clearinghouse state changes but nothing reflects it in our streamed cache. The REST#refreshSpotStatepath only runs on cold-start and standalone fetches, so the cached snapshot goes stale on every on-chain event (local trade, external-client trade, funding, liquidation, deposit, transfer).AccountState.availableToTradeBalancedoesn't exist — order-entry surfaces readavailableBalance(= HLwithdrawable), which is always$0on Unified-mode accounts whose collateral is held as spot USDC. Users with tradeable balance see the app refuse to open an order.totalBalanceon the three account-state paths sumsperps.accountValue + spot.totalwithout subtractingspot.hold. On Unified/PM the held margin is reported in both fields, so pre-fixtotalBalanceinflates by the margin amount whenever a limit order is placed and deflates when cancelled — even though no wealth changed hands.What this PR does
spotStateWebSocket channel alongside the existingwebData2/3user-data subscription. Handler updates#cachedSpotState, bumps#spotStateGenerationto invalidate any in-flight REST race, and re-runs#aggregateAndNotifySubscribersso UI consumers see spot-folded totals within one network round-trip of the change. REST fallback stays for cold-start and the standalone path.AccountState.availableToTradeBalanceas a first-class optional field:withdrawable + (spot.total − spot.hold)for HL,availableBalancetrivial default for other providers. Order-entry surfaces (PerpsMarketBalanceActions,PerpsMarketDetailsView,useDefaultPayWithTokenWhenNoPerpsBalance,usePerpsOrderForm) readavailableToTradeBalance ?? availableBalance. Withdraw path (PerpsWithdrawView) keeps readingavailableBalanceunchanged so the withdraw row never leaks the spot fold.spot.holdfrom thetotalBalancesum in both the single-DEX adapter and the aggregated fold helper. On Standard modespot.hold = 0so the subtraction is a no-op; on Unified/PM it cancels the double-count. Result:totalBalanceno longer ping-pongs on limit place/cancel, matching HL web.HyperLiquidProvider.getExchangeClient()as a non-interface escape hatch for agentic validation flows that drive HL mutations directly.perps-withdraw-available-balance-texttestID to anchor the non-regression check that withdraw keeps renderingavailableBalance.account-abstraction-modes.md,portfolio-margin.md,margin-tiers.md, updatedsubscriptions.md+margining.md) so the code rationale can cite them.Follow-ups (not in this PR)
availableBalance → withdrawableBalance(TAT-3047) — pure rename against main, kept separate so OTA cherry-picks don't see symbol drift.Changelog
CHANGELOG entry: Fixed Perps balance not refreshing after trades, funding, or transfers for HyperLiquid users, and corrected total balance inflation on Unified-mode accounts.
Related issues
Fixes: TAT-3016
Supersedes: #29150, #29217 (both closed).
Manual testing steps
Screenshots/Recordings
Before
After
Pre-merge author checklist
Performance checks (if applicable)
Pre-merge reviewer checklist