refactor(perps): split AccountState balances + mode-aware spot fold#29303
Conversation
…o-sweep spot→perps on withdraw AccountState now carries three purpose-built fields: - totalBalance — venue equity (display only) - spendableBalance — max collateral for a new position (order-entry) - withdrawableBalance — max reachable to user's wallet (withdraw) Previously `availableBalance` was overloaded between order-entry and withdraw paths, and the `availableToTradeBalance` hotfix (TAT-3016) carried an optional fallback that every consumer had to re-derive. This commit retires both, pushing per-intent semantics into the type with JSDoc per field. Per-provider mapping is provider-internal: - HL Unified: withdrawable + freeSpotUSDC (both new fields fold spot) - HL Standard: withdrawable (spot.hold = 0, math collapses) - MYX: walletBalance (spendable === withdrawable) HyperLiquidProvider.withdraw() now auto-sweeps spot→perps via usdClassTransfer when the perps clearinghouse alone can't cover the requested amount but the user's spendable does. The caller (UI) never branches on provider — it just reads withdrawableBalance. If the shortfall exceeds free spot, the withdraw is rejected with a clear error. Fixes: TAT-3047 — unblocks withdraw for Unified-mode accounts whose collateral sits entirely in spot USDC (previously showed \$0 max and refused to submit). Scope: - AccountState + SubAccountState types in app/controllers/perps/types - HL adapter, accountUtils spot-fold, subscription-service aggregation - HyperLiquidProvider.withdraw() sweep + polling - MYX adapter - 30+ consumer files migrated by intent (spendable vs withdrawable vs total) - Hook params renamed (usePerpsOrderValidation, validateBalance, getMaxAllowedAmount) - Test fixtures + mocks updated across ~40 test files
|
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. |
…tomic HL's usdClassTransfer is applied at the validator layer and is visible to the perps clearinghouse immediately on `status === 'ok'`. The 10s poll on clearinghouseState was defensive paranoia added before SDK semantics were verified. Removing it eliminates a failure mode where a delayed `ok` response would throw a misleading "sweep did not confirm before timeout" error while the funds had already moved. After-sweep path is now: transfer → check status → withdraw3.
Leftover synonym from when the field was called availableToTradeBalance. With the rename, the local alias just adds noise — local now matches the field name.
- prevAvailableBalanceRef → prevSpendableBalanceRef (deposit hooks) - prevAvailableBalanceRef → prevWithdrawableBalanceRef (withdraw progress) - validateBalance JSDoc param description - UsePerpsOrderValidationParams.spendableBalance JSDoc - DevLogger label 'Available Perps Balance' → 'Withdrawable Balance'
… new balance fields Persisted PerpsController.accountState carries the legacy availableBalance / availableToTradeBalance keys for existing installs. Without a migration, rehydration on upgrade produces an accountState that consumers can't read, leaving balance UIs at 0/— until a fresh fetch lands — which blocks order-entry and withdraw on cold start, offline use, or slow startup. Migration 133 maps: - availableToTradeBalance ?? availableBalance → spendableBalance - availableToTradeBalance ?? availableBalance → withdrawableBalance - subAccountBreakdown[*].availableBalance → spendableBalance + withdrawableBalance The in-repo agentic validation/provisioning flows under scripts/perps/agentic/teams/perps/ (evals.json, pre-conditions.js, hl-balance-validation.json, hl-provision-fixture.json) also referenced the old field names. Renamed in place so the probes read real values instead of silently seeing undefined/zero.
…, not folded accountState
hl-provision-fixture.json transfer-to-spot/to-perp previously read
accountState.{withdrawable,spendable}Balance when transferAmount='max'.
After the balance reshape those fields fold free spot USDC into both,
so:
- to-spot max would over-request (asks to move spot-inclusive amount
from perps, but usdClassTransfer only moves perps-side funds)
- to-perp max computed as spendable-withdrawable is always 0, so the
flow refuses to transfer even when spot USDC is available
Query HL REST directly for raw values:
- to-spot: clearinghouseState.withdrawable (perps clearinghouse only)
- to-perp: spotClearinghouseState USDC {total - hold} (free spot)
Mainnet/testnet URL picked from PerpsController.state.isTestnet.
… balance contract Three fallout spots missed in the prior field rename: 1. hyperLiquidAdapter.test.ts asserted the adapter still returns availableToTradeBalance. Removed — the field no longer exists. 2. HyperLiquidSubscriptionService.test.ts expected subAccountBreakdown entries without withdrawableBalance. Production code emits it now. 3. tests/framework/fixtures/json/default-fixture.json + tests/component-view/renderers/perpsViewRenderer.tsx seed accountState directly (bypassing redux-persist migration 133), so legacy availableBalance would leave consumers reading 0 on any fixture-backed run. Migrated the seed shapes to the new fields.
…State shape The cold-start disk cache (PERPS_DISK_CACHE_USER_DATA) stores AccountState snapshots via buildUserDataPayload and is hydrated into cachedUserDataByProvider by hydrateFromDiskSync before the first WS tick. Existing installs have legacy-shape accountState (availableBalance / availableToTradeBalance) sitting in that cache, so consumers that read spendable/withdrawableBalance via getPreloadedData see undefined and render $0 / disabled controls until the first live fetch lands. The disk payload has no in-payload version field, so the simplest no-tech-debt path is to bump the storage key. Upgraded installs see an empty new cache on first run, fall through to skeleton/fallback, then backfill from WS as normal. The orphan old-key blob stays on disk (small, eventually cleaned by any reset/logout flow).
…ble contract
usePerpsAdjustMarginData exposes spendableBalance as the cap for
add-margin. On Unified accounts with spot USDC collateral that value
exceeds what updateIsolatedMargin() can actually draw (clearinghouse
withdrawable only), so amounts between clearinghouse and spendable
become selectable in the UI and then fail at submission.
Mirror the withdraw() sweep: before calling updateIsolatedMargin with
a positive (ADD) amount, if the shortfall vs perps clearinghouse is
non-zero, run usdClassTransfer({ toPerp: true }) for the gap. Symmetric
with withdraw, and keeps the provider honouring the spendableBalance
contract — UI reads one number, provider performs internal moves.
REMOVE direction is unaffected (no sweep).
…Unified mode Live probe against the Trading fixture (Unified-mode mainnet) shows HL rejects usdClassTransfer with 'Action disabled when unified account is active'. Both sweeps (withdraw + updateMargin) would fail on every Unified-mode user — the default on app.hyperliquid.xyz and most of our traffic. That is a worse regression than the original TAT-3047 symptom (UI showed \$0, didn't submit). Correct behaviour: - Unified mode: HL unifies the spot + perps ledger internally. withdraw3 and updateIsolatedMargin draw from the unified balance directly. No sweep needed. - Standard mode: spot and perps are separate; moving funds requires explicit user action on HL web. Our mobile app did not handle this pre-refactor and this PR does not introduce it. This PR therefore reduces to rename-only: the three-field contract (totalBalance / spendableBalance / withdrawableBalance), the persisted- state migration, and consumer updates. withdraw() and updateMargin() bodies revert to their pre-refactor shapes aside from the field name changes in validation. Revert-of: 2708b11 (updateMargin sweep), portion of 5f07189 + a3a1798 (withdraw sweep).
…AT-3047 Three composable flows under scripts/perps/agentic/teams/perps/flows/: - hl-balance-contract-check: asserts PerpsController.accountState exposes the new three-field contract (spendable / withdrawable / total) with no legacy keys. Inputs: address, phaseLabel. Runs against any account. - hl-balance-math-check: asserts the spot-fold math by using the controller's subAccountBreakdown as perps-side truth (covers HIP-3 multi-DEX) plus spotClearinghouseState REST as spot-side truth. Expected: spendable/withdrawable = Σ(breakdown.spendable) + freeSpot; total = Σ(breakdown.total) + spot.total − spot.hold; spendable === withdrawable. Tolerates a small rounding epsilon. - hl-empty-state-check: asserts zero-balance accounts surface the PerpsEmptyBalance affordance (Add Funds button) on PerpsMarketListView. These compose cleanly via \`call\` so future balance-field refactors can reuse them with different fixture accounts. Recipe usage (local, .task/-gated): see .task/fix/tat-3047-0424-1139/ VALIDATION.md for the multi-account validation matrix and run output.
Matrix + composable flows + live-run evidence for the balance-contract reshape. Documents coverage across three fixture accounts (Trading Unified spot-funded + HIP-3, dev1 spot-only clean, Account 6 zero) and calls out the Standard-mode follow-up as out of scope.
… live numbers New composable flow hl-standard-mode-fold-check.json compares the adapter's actual spendable/withdrawable against Standard-mode semantics (perps-only, no spot fold) and asserts `observedInflation ≈ freeSpot` within ε=0.05 — a regression guard that both documents the existing limitation and fails if a future mode-aware fix unexpectedly removes it without updating this assertion. Recipe phase 2c now runs this on dev2 post Unified→Standard flip. Live measurement at the time of this commit: spot.free = $10.01, standardSemanticExpected.spendable = $0, adapterActual.spendable = $10.01, observedInflation = $10.01 ≈ freeSpot ✓ VALIDATION.md updated with: - HL web checkbox → abstraction mode mapping (explicit Unified vs Standard) - Phase 2c live numbers captured as evidence - Standard-mode fold limitation called out as pre-existing and out of scope for this PR, with the flow serving as its regression guard.
Previously spot USDC was folded into spendable/withdrawable unconditionally,
which is correct for HL Unified/Portfolio (spot is unified with perps) but
inflates balances on Standard mode where spot is a separate ledger HL's
backend can't auto-draw from. UI approved submissions HL would reject.
Design — clean separation:
- accountUtils.addSpotBalanceToAccountState takes { foldIntoCollateral }
option; provider-agnostic utility, no HL-specific imports.
- hyperliquid-types.ts owns HyperLiquidAbstractionMode (alias of HL SDK's
UserAbstractionResponse) and hyperLiquidModeFoldsSpot(mode) helper.
- HyperLiquidProvider.getAccountState fetches userAbstraction in parallel
with clearinghouse + spot, computes the flag, passes to the util.
- HyperLiquidSubscriptionService caches the mode alongside #cachedSpotState
(refreshed together, cleared together on cleanup) and gates both fold
call sites on the helper.
Migration 133 asymmetric mapping: legacy availableBalance maps to
withdrawableBalance (perps-only pre-refactor value);
availableToTradeBalance ?? availableBalance maps to spendableBalance.
Minimises cold-start inflation window for upgraded Standard-mode users.
Live proof on dev2 flipped to Standard:
spot.free = \$10.01
standardSemanticExpected.spendable = 0 (perps-only)
adapterActual.spendable = 0
observedInflation = 0 — gate works.
Recipe phase 2c passes hl-balance-math-check with foldIntoCollateral=false
and hl-standard-mode-fold-check asserting standardModeCorrect.
Tests: accountUtils.test.ts gains 2 cases; hyperliquid-types.test.ts is
new and covers all 5 modes plus null/undefined. Recipe 36/36 across
Trading + dev1 + dev2 Unified-to-Standard-to-Unified + Account 6.
… fixtures - HyperLiquidSubscriptionService: throttle constant moved to perpsConfig, the doc-comment that documented it stayed behind above an unrelated field declaration. Remove the dangling comment. - useTransactionCustomAmount tests: three perps-withdraw fixtures only seeded withdrawableBalance; pair them with spendableBalance to match the peer fixture in the same file.
…ance-contract-reshape
… validation notes - Added "schema_version": 1 to the JSON structure for better version control. - Enhanced validation notes for shared-screenshot-market-list and shared-screenshot-withdraw actions to clarify expected behaviors and outcomes related to balance displays, addressing TAT-3047 fixes.
…ance-contract-reshape
geositta
left a comment
There was a problem hiding this comment.
@abretonc7s Please see my comment regarding the dead map. I would approve, conditional on that. Since leaving the dead #abstractionModeLastFetchedAtByUser with no readers makes the next person to touch this file second guess whether they are missing a code path.
Map was set in two write sites and cleared in cleanUp/clearAll, but never read. Throttle gate uses #abstractionModeLastWsRefreshAtByUser exclusively. Removing the dead state so the intent of the remaining map is unambiguous.
…ance-contract-reshape # Conflicts: # app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx # app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts # app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts # app/components/UI/Perps/hooks/useWithdrawValidation.ts # app/components/Views/confirmations/components/perps-confirmations/perps-withdraw-balance/perps-withdraw-balance.tsx # app/components/Views/confirmations/hooks/alerts/useInsufficientPerpsBalanceAlert.ts # app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts # app/controllers/perps/providers/HyperLiquidProvider.test.ts # app/controllers/perps/providers/HyperLiquidProvider.ts # app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts # app/controllers/perps/services/HyperLiquidSubscriptionService.ts # app/controllers/perps/types/hyperliquid-types.test.ts # app/controllers/perps/types/hyperliquid-types.ts # app/controllers/perps/utils/accountUtils.test.ts # app/controllers/perps/utils/accountUtils.ts
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ece3bdb. Configure here.
|
|
||
| expect(accountState.availableToTradeBalance).toBe('0'); | ||
| expect(accountState.spendableBalance).toBe('0'); | ||
| expect(accountState.withdrawableBalance).toBe('0'); |
There was a problem hiding this comment.
Stale availableBalance in HyperLiquidProvider withdraw test mocks
Medium Severity
Three getAccountState mocks in HyperLiquidProvider.test.ts still return { availableBalance: '5000' } instead of the new { spendableBalance, withdrawableBalance } shape. The actual provider now reads accountState.spendableBalance (e.g., in the margin guard at line 4777) and accountState.withdrawableBalance (in #getBalanceForDex). These mocks will produce NaN / undefined when the provider parses the response, causing the withdraw-related tests to pass vacuously or for the wrong reason.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit ece3bdb. Configure here.
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
Tag selection rationale:
The changes are well-contained to the Perps domain with no impact on non-Perps flows (no shared navigation, no shared controllers beyond PerpsController, no changes to Engine initialization). Performance Test Selection: |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #29303 +/- ##
==========================================
- Coverage 82.15% 81.85% -0.31%
==========================================
Files 5178 5245 +67
Lines 137450 138757 +1307
Branches 31079 31470 +391
==========================================
+ Hits 112924 113573 +649
- Misses 16875 17458 +583
- Partials 7651 7726 +75 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|





Description
Two outcomes:
API surface —
AccountStatebalances are purpose-built and self-documenting. Three fields with JSDoc-documented per-provider semantics replace the overloadedavailableBalance/availableToTradeBalancepair:totalBalance— venue equity (display only)spendableBalance— max collateral for a new position (order entry)withdrawableBalance— max reachable to the user's external wallet (withdraw)Full coverage of HyperLiquid abstraction-mode combinations. The provider, aggregation, and UI now read the right field for the right surface across every Unified / Standard / Portfolio Margin topology, validated end-to-end on mainnet against four fixture accounts and a live Unified ↔ Standard ↔ Unified flip.
User-visible fix: Unified-mode accounts whose collateral sits entirely in spot USDC now show the correct withdraw max (matches
app.hyperliquid.xyz"Withdrawable") and submission proceeds. HL's Unified abstraction drawswithdraw3from the unified balance directly — no client-side sweep.Mode-aware spot fold
accountUtils.addSpotBalanceToAccountStatetakes an{ foldIntoCollateral: boolean }option. Provider owns the decision:hyperliquid-types.tshostsHyperLiquidAbstractionMode(alias of HL SDK'sUserAbstractionResponse) andhyperLiquidModeFoldsSpot(mode)—truefor Unified / Portfolio / default (and unknown-mode where the majority sits),falsefor Standard / DEX-abstraction.HyperLiquidProvider.getAccountStatefetchesuserAbstractionalongside clearinghouse + spot state.HyperLiquidSubscriptionServicecaches mode keyed by lowercase address, refreshes on spot WS ticks viaABSTRACTION_MODE_REFRESH_THROTTLE_MS = 60_000, and re-aggregates subscribers when the fold side flips.Per-provider mapping
totalBalancespendableBalancewithdrawableBalanceaccountValue + spot.total − spot.holdwithdrawable + freeSpotUSDCwithdrawable + freeSpotUSDCaccountValue + spot.total − spot.hold(display)withdrawable(perps-only)withdrawable(perps-only)walletBalance + marginUsed + unrealizedPnlwalletBalancewalletBalanceUpgrade safety
accountState:spendableBalance←availableToTradeBalance ?? availableBalancewithdrawableBalance←availableBalance(perps-only, safe for Standard until first fresh fetch)subAccountBreakdownentries migrate per-DEXPERPS_DISK_CACHE_USER_DATA→_V2prevents legacy-shape hydration. One cold start falls through to skeleton; WS backfills.Scope
app/controllers/perps/typesaccountUtils, subscription service aggregation#getBalanceForDexfield referencesValidation matrix + live captured values:
docs/perps/perps-account-abstraction-and-balance-contract.md.Not in this PR (deliberate)
@metamask/perps-controllercore mirror — needs separate release coordinationChangelog
CHANGELOG entry: Fixed HyperLiquid withdraw showing $0 max on Unified-mode accounts whose collateral sits in spot USDC; prevented over-approval of withdraws and orders on Standard-mode accounts where spot is a separate ledger.
Related issues
Fixes: TAT-3047
Manual testing steps
See
docs/perps/perps-account-abstraction-and-balance-contract.mdfor the full matrix + live-run evidence.Recipe used to validate this branch (multi-phase, mainnet)
The recipe orchestrates four composable flows (
hl-balance-contract-check,hl-balance-math-check,hl-empty-state-check,hl-standard-mode-fold-check) plushl-provision-fixtureacross four fixture accounts (Trading / dev1 / dev2 / Account 6) and exercises a live Unified ↔ Standard ↔ Unified mode flip on dev2.Recipe:
scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.jsonRun locally:
Screenshots/Recordings
Before
Unified Mode + spot-only USDC → withdraw max shows $0, submit disabled.
After
Unified Mode + spot-only USDC → withdraw max shows combined balance; submit proceeds via HL's Unified abstraction (verified via direct
withdraw3probe on Trading fixture: $1.01 withdrawn, spot USDC decreased by $1.01, perps unchanged).Pre-merge author checklist
Performance checks (if applicable)
Pre-merge reviewer checklist
Note
Medium Risk
Medium risk because it changes which balance fields drive order validation, withdraw limits, empty-state gating, and deposit/withdraw progress completion; incorrect mapping could block trades/withdraws or mislead users about available funds.
Overview
Replaces the overloaded perps balance fields across the app by migrating consumers from
availableBalance/availableToTradeBalanceto the newAccountStatecontract:spendableBalance(order entry/margin add/Pay-with) andwithdrawableBalance(withdraw + confirmations). This updates core UI surfaces (PerpsOrderView,PerpsMarketDetailsView,PerpsWithdrawView, balance tooltips/rows) and related hooks/utilities (usePerpsOrderForm,usePerpsOrderValidation,useWithdrawValidation,useDefaultPayWithTokenWhenNoPerpsBalance, token filter/payment tokens).Tightens state/progress logic around balances: deposit completion/toasts and deposit-progress now key off
spendableBalance(avoiding false clears fromtotalBalancePnL moves), withdraw-progress keys offwithdrawableBalance, and the home balance component’s “empty state” now gates ontotalBalance(treating non-finite sentinel values as empty) while displayingspendableBalance. Cache safety: bumps the disk user-data cache key toPERPS_DISK_CACHE_USER_DATA_V2and introducesABSTRACTION_MODE_REFRESH_THROTTLE_MS.Updates extensive unit/e2e tests and mocks to the new fields and adds targeted edge-case coverage (e.g., funded users with
spendableBalance=0due to locked margin, and deposits where onlytotalBalanceincreases via unrealized PnL).Reviewed by Cursor Bugbot for commit ece3bdb. Bugbot is set up for automated code reviews on this repo. Configure here.