Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,11 @@
"count": 3
}
},
"packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"packages/perps-controller/src/utils/myxAdapter.ts": {
"@typescript-eslint/no-base-to-string": {
"count": 2
Expand Down
12 changes: 6 additions & 6 deletions packages/perps-controller/.sync-state.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"lastSyncedMobileCommit": "cf20fa64fa5d919c87005f35578498b37f522e2a",
"lastSyncedMobileBranch": "feat/unified-account",
"lastSyncedCoreCommit": "6f8329297fd1d61ff72d8359d32856d1bda7559f",
"lastSyncedCoreBranch": "feat/perps/controller-apr-30",
"lastSyncedDate": "2026-04-30T15:10:24Z",
"sourceChecksum": "a1ead6bd8f4bf1aae32c95aa27a3b6cddf1c4e4b74ea4761952ea313cb109c80"
"lastSyncedMobileCommit": "355c9b656b9b51e154212666275f6d83ccb60fc5",
"lastSyncedMobileBranch": "main",
"lastSyncedCoreCommit": "fe175509d6f54207a06ebc710a9e5c07c5bc2cd8",
"lastSyncedCoreBranch": "feat/perps/sync-4-may-2026",
"lastSyncedDate": "2026-05-04T14:25:52Z",
"sourceChecksum": "2e7e9eab35667ca1bed2e2c3cd2f59361467910574b428ec47e9c6a7d0958bf5"
}
6 changes: 6 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING:** Rename `AccountState.availableBalance` to `spendableBalance` and `AccountState.availableToTradeBalance` to `withdrawableBalance` for clearer semantics across abstraction modes ([#8678](https://github.com/MetaMask/core/pull/8678))
- Mode-aware spot fold: `addSpotBalanceToAccountState` now folds free spot USDC into both `spendableBalance` and `withdrawableBalance` for Unified/Portfolio modes, while Standard/DEX-abstraction modes keep spot separate ([#8678](https://github.com/MetaMask/core/pull/8678))
- Add throttled WS-driven `userAbstraction` refresh so HL-web mode flips propagate back without requiring a restart or account switch ([#8678](https://github.com/MetaMask/core/pull/8678))

## [5.0.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ export const MAINNET_HIP3_CONFIG = {
* HIP-3 margin management configuration
* Controls margin buffers and auto-rebalance behavior for HIP-3 DEXes with isolated margin
*
* Background: HyperLiquid validates availableBalance >= totalRequiredMargin BEFORE reallocating
* Background: HyperLiquid validates spendableBalance >= totalRequiredMargin BEFORE reallocating
* existing locked margin. This requires temporary over-funding when increasing positions,
* followed by automatic cleanup to minimize locked capital.
*/
Expand Down
17 changes: 15 additions & 2 deletions packages/perps-controller/src/constants/perpsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,24 @@ export const PROVIDER_CONFIG = {
MYX_TESTNET_ONLY: false,
} as const;

// Disk-backed cold-start cache keys and throttle interval
// Disk-backed cold-start cache keys and throttle interval.
// The user-data key ends in _V2 because the AccountState balance contract
// changed (TAT-3047) and has no in-payload version field. Bumping the key
// forces a one-time empty cache on upgrade — consumers fall through to
// skeleton/fallback until the first WS tick, avoiding stale legacy-shape
// reads that would surface as $0 balances.
export const PERPS_DISK_CACHE_MARKETS = 'PERPS_DISK_CACHE_MARKETS';
export const PERPS_DISK_CACHE_USER_DATA = 'PERPS_DISK_CACHE_USER_DATA';
export const PERPS_DISK_CACHE_USER_DATA = 'PERPS_DISK_CACHE_USER_DATA_V2';
export const PERPS_DISK_CACHE_THROTTLE_MS = 30_000;

/**
* Minimum interval between WebSocket-triggered HL `userAbstraction`
* refreshes. Balances picking up HL-web mode flips (Unified ↔ Standard)
* promptly against burning REST quota on every spot tick. Covers the
* observed user pattern of flipping mode once per session at most.
*/
export const ABSTRACTION_MODE_REFRESH_THROTTLE_MS = 60_000;

/**
* Build the standard provider:network cache key from controller state.
*
Expand Down
66 changes: 42 additions & 24 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2721,7 +2721,7 @@ export class HyperLiquidProvider implements PerpsProvider {

const accountState = await infoClient.clearinghouseState(queryParams);
const adapted = adaptAccountStateFromSDK(accountState);
return parseFloat(adapted.availableBalance);
return parseFloat(adapted.withdrawableBalance);
}

/**
Expand Down Expand Up @@ -3014,7 +3014,7 @@ export class HyperLiquidProvider implements PerpsProvider {
const orderIsLong = isBuy;

if (existingIsLong === orderIsLong) {
// Increasing position - HyperLiquid validates availableBalance >= totalRequiredMargin
// Increasing position - HyperLiquid validates spendableBalance >= totalRequiredMargin
// BEFORE reallocating existing locked margin. Must transfer TOTAL margin temporarily.
const existingSize = Math.abs(parseFloat(existingPosition.size));
const existingMargin = parseFloat(existingPosition.marginUsed);
Expand Down Expand Up @@ -3130,7 +3130,7 @@ export class HyperLiquidProvider implements PerpsProvider {
'🔄 HyperLiquidProvider: Auto-rebalancing excess margin back to main DEX',
{
dex: dexName,
availableBalance: postOrderBalance.toFixed(2),
spendableBalance: postOrderBalance.toFixed(2),
desiredBuffer: desiredBuffer.toFixed(2),
excessAmount: excessAmount.toFixed(2),
destinationDex: transferInfo.sourceDex,
Expand Down Expand Up @@ -4769,6 +4769,20 @@ export class HyperLiquidProvider implements PerpsProvider {
ntli,
});

// Guard: confirm spendableBalance can cover margin addition.
// spendableBalance is already mode-aware (includes free spot in Unified,
// excludes it in Standard), so no extra spot fetch needed.
if (amountFloat > 0) {
const accountState = await this.getAccountState();
const spendable = parseFloat(accountState.spendableBalance);

if (spendable < amountFloat) {
throw new Error(
`Insufficient balance for margin addition: need ${amountFloat}, available ${spendable.toFixed(2)}`,
);
}
}

// Call SDK to update isolated margin
const exchangeClient = this.#clientService.getExchangeClient();
const result = await exchangeClient.updateIsolatedMargin({
Expand Down Expand Up @@ -5852,7 +5866,9 @@ export class HyperLiquidProvider implements PerpsProvider {
this.#clientService.isTestnetMode() ? 'TESTNET' : 'MAINNET',
);

// Get Spot balance (global, not DEX-specific) and Perps states across all DEXs.
// Get Spot balance, Perps states across DEXs, and the HL abstraction
// mode (Unified / Standard / Portfolio / DEX-abstraction). Mode decides
// whether spot USDC is perps collateral — see addSpotBalanceToAccountState.
// One transient DEX failure should not blank the entire account state.
const [spotStateResult, perpsStateResult, abstractionResult] =
await Promise.allSettled([
Expand Down Expand Up @@ -5947,7 +5963,8 @@ export class HyperLiquidProvider implements PerpsProvider {
`DEX ${result.dex ?? 'main'} account state:`,
{
totalBalance: dexAccountState.totalBalance,
availableBalance: dexAccountState.availableBalance,
spendableBalance: dexAccountState.spendableBalance,
withdrawableBalance: dexAccountState.withdrawableBalance,
marginUsed: dexAccountState.marginUsed,
unrealizedPnl: dexAccountState.unrealizedPnl,
},
Expand All @@ -5957,23 +5974,26 @@ export class HyperLiquidProvider implements PerpsProvider {
const aggregatedAccountState = addSpotBalanceToAccountState(
aggregateAccountStates(dexAccountStates),
spotState,
{
foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode),
},
{ foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode) },
);

// Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts)
const subAccountBreakdown: Record<
string,
{ availableBalance: string; totalBalance: string }
{
spendableBalance: string;
withdrawableBalance: string;
totalBalance: string;
}
> = {};
perpsStateResults.forEach((result) => {
const { dex, data: perpsState } = result;
const dexAccountState = adaptAccountStateFromSDK(perpsState);
const subAccountKey = dex ?? ''; // Empty string for main DEX

subAccountBreakdown[subAccountKey] = {
availableBalance: dexAccountState.availableBalance,
spendableBalance: dexAccountState.spendableBalance,
withdrawableBalance: dexAccountState.withdrawableBalance,
totalBalance: dexAccountState.totalBalance,
};
});
Expand Down Expand Up @@ -7053,16 +7073,10 @@ export class HyperLiquidProvider implements PerpsProvider {
'HyperLiquidProvider: CHECKING ACCOUNT BALANCE',
);
const accountState = await this.getAccountState();
// Release-branch bridge for Unified Account: availableToTradeBalance
// includes collateral HL can draw in target mode. The larger balance
// contract will replace this with an explicit withdrawableBalance field.
const availableBalance = parseFloat(
accountState.availableToTradeBalance ?? accountState.availableBalance,
);
const withdrawableBalance = parseFloat(accountState.withdrawableBalance);
this.#deps.debugLogger.log('HyperLiquidProvider: ACCOUNT BALANCE', {
availableBalance,
clearinghouseAvailableBalance: accountState.availableBalance,
availableToTradeBalance: accountState.availableToTradeBalance,
withdrawableBalance,
spendableBalance: accountState.spendableBalance,
totalBalance: accountState.totalBalance,
marginUsed: accountState.marginUsed,
unrealizedPnl: accountState.unrealizedPnl,
Expand All @@ -7080,22 +7094,26 @@ export class HyperLiquidProvider implements PerpsProvider {
const withdrawAmount = parseFloat(params.amount);
this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL AMOUNT', {
requestedAmount: withdrawAmount,
availableBalance,
sufficientBalance: withdrawAmount <= availableBalance,
withdrawableBalance,
sufficientBalance: withdrawAmount <= withdrawableBalance,
});

// Validate against withdrawableBalance — the mode-aware cap.
// No spot sweep: withdrawableBalance already reflects what withdraw3
// can pull. In Unified mode HL handles cross-wallet internally; in
// Standard mode spot is not withdrawable via perps.
const balanceValidation = validateBalance(
withdrawAmount,
availableBalance,
withdrawableBalance,
);
if (!balanceValidation.isValid) {
this.#deps.debugLogger.log(
'HyperLiquidProvider: INSUFFICIENT BALANCE',
{
error: balanceValidation.error,
requestedAmount: withdrawAmount,
availableBalance,
difference: withdrawAmount - availableBalance,
withdrawableBalance,
difference: withdrawAmount - withdrawableBalance,
},
);
throw new Error(balanceValidation.error);
Expand Down
6 changes: 4 additions & 2 deletions packages/perps-controller/src/providers/MYXProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,8 @@ export class MYXProvider implements PerpsProvider {
...this.#getErrorContext('getAccountState'),
});
return {
availableBalance: '0',
spendableBalance: '0',
withdrawableBalance: '0',
totalBalance: '0',
marginUsed: '0',
unrealizedPnl: '0',
Expand Down Expand Up @@ -958,7 +959,8 @@ export class MYXProvider implements PerpsProvider {
setTimeout(
() =>
params.callback({
availableBalance: '0',
spendableBalance: '0',
withdrawableBalance: '0',
totalBalance: '0',
marginUsed: '0',
unrealizedPnl: '0',
Expand Down
Loading
Loading