Skip to content

Commit 06e81f9

Browse files
committed
fix: bugbots
1 parent d3658d1 commit 06e81f9

4 files changed

Lines changed: 183 additions & 5 deletions

File tree

packages/perps-controller/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- The collateral check fails open (treats the DEX as USDC-collateral) when the collateral token can't be resolved against spot metadata, so incomplete metadata doesn't spuriously block an otherwise-valid market.
1717
- Removed the now-unreachable USDH auto-swap machinery this replaces (spot USDH/USDC balance lookups, the USDC→USDH spot swap, and the auto-swap orchestration).
1818
- Subscribe to HyperLiquid's `fastAssetCtxs` WebSocket feed for mark/mid price updates, replacing `assetCtxs` as the latency-sensitive price source now that HyperLiquid has slowed the public `assetCtxs` feed cadence ([#9530](https://github.com/MetaMask/core/pull/9530))
19-
- `assetCtxs` continues to populate funding, open interest, volume, and oracle price data, and remains the price source for any symbol `fastAssetCtxs` doesn't cover.
19+
- `assetCtxs` continues to populate funding, open interest, volume, and oracle price data, and no longer writes prices for any symbol `fastAssetCtxs` covers, so a slower `assetCtxs` batch tick can't overwrite a fresher `fastAssetCtxs` price; it remains the price source only for symbols outside `fastAssetCtxs`' coverage (e.g. HIP-3 DEX markets).
2020
- `fastAssetCtxs` is a single global subscription (the HyperLiquid SDK exposes no per-DEX variant): the first message is a full snapshot keyed by coin, and later messages contain diffs for only the coins that changed. Coins without an active price subscriber are ignored.
2121
- Established alongside the global `allMids` subscription, restored together on WebSocket reconnect, and torn down on `clearAll()`. Subscribe attempts use the same 3-attempt/500ms-backoff retry as `assetCtxs` for transient SDK errors.
2222

packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ export class HyperLiquidSubscriptionService {
145145

146146
#globalFastAssetCtxsPromise?: Promise<void>; // Track in-progress subscription
147147

148+
// Coins seen in any fastAssetCtxs event (snapshot or diff). Once a coin
149+
// appears here, the per-DEX assetCtxs handler stops writing its price into
150+
// #cachedPriceData, since fastAssetCtxs is the fresher/authoritative source
151+
// for that coin going forward. Cleared on clearAll() and when the
152+
// fastAssetCtxs subscription is re-established after a reconnect, so
153+
// assetCtxs can serve prices again until a fresh snapshot arrives.
154+
readonly #fastAssetCtxsCoins = new Set<string>();
155+
148156
readonly #globalActiveAssetSubscriptions = new Map<string, ISubscription>();
149157

150158
// Track in-progress activeAssetCtx subscription promises to prevent leaks
@@ -3133,6 +3141,16 @@ export class HyperLiquidSubscriptionService {
31333141
const changedSymbols = new Set<string>();
31343142

31353143
for (const coin in data) {
3144+
if (!hasProperty(data, coin)) {
3145+
continue;
3146+
}
3147+
3148+
// Mark this coin as covered by fastAssetCtxs regardless of whether
3149+
// there's currently a subscriber, so the slower per-DEX assetCtxs
3150+
// handler knows to defer to this feed for the coin's price once
3151+
// there is one.
3152+
this.#fastAssetCtxsCoins.add(coin);
3153+
31363154
// Skip coins nobody is subscribed to (snapshot messages include
31373155
// every asset on the exchange, most of which have no subscriber)
31383156
if (!this.#priceSubscribers.get(coin)?.size) {
@@ -3141,7 +3159,7 @@ export class HyperLiquidSubscriptionService {
31413159

31423160
const ctx = data[coin];
31433161
const priceRaw = ctx.midPx ?? ctx.markPx;
3144-
if (priceRaw === undefined) {
3162+
if (priceRaw === undefined || priceRaw === null) {
31453163
continue;
31463164
}
31473165

@@ -3634,9 +3652,13 @@ export class HyperLiquidSubscriptionService {
36343652

36353653
this.#marketDataCache.set(asset.name, marketData);
36363654

3637-
// HIP-3: Extract price from assetCtx and update cached prices
3655+
// HIP-3: Extract price from assetCtx and update cached prices.
3656+
// Skip symbols fastAssetCtxs already covers (TAT-3387 owns their
3657+
// price path with fresher, ~5s-cadence data); assetCtxs remains
3658+
// the price source only for symbols outside fastAssetCtxs'
3659+
// coverage, e.g. HIP-3 dex:SYMBOL assets.
36383660
const price = ctx.midPx?.toString() ?? ctx.markPx?.toString();
3639-
if (price) {
3661+
if (price && !this.#fastAssetCtxsCoins.has(asset.name)) {
36403662
// For HIP-3 DEXs, meta() returns asset.name already containing the DEX prefix
36413663
// (e.g., "xyz:XYZ100"), so use it directly
36423664
const symbol = asset.name;
@@ -4176,9 +4198,12 @@ export class HyperLiquidSubscriptionService {
41764198
// Re-establish the subscription
41774199
this.#ensureGlobalAllMidsSubscription();
41784200

4179-
// Re-establish the fastAssetCtxs subscription alongside allMids (TAT-3387)
4201+
// Re-establish the fastAssetCtxs subscription alongside allMids (TAT-3387).
4202+
// Clear fastAssetCtxsCoins so assetCtxs can serve prices in the gap
4203+
// until the fresh post-reconnect snapshot re-establishes coverage.
41804204
this.#globalFastAssetCtxsSubscription = undefined;
41814205
this.#globalFastAssetCtxsPromise = undefined;
4206+
this.#fastAssetCtxsCoins.clear();
41824207
this.#ensureGlobalFastAssetCtxsSubscription();
41834208
}
41844209

@@ -4466,6 +4491,7 @@ export class HyperLiquidSubscriptionService {
44664491
}
44674492
this.#globalFastAssetCtxsSubscription = undefined;
44684493
this.#globalFastAssetCtxsPromise = undefined;
4494+
this.#fastAssetCtxsCoins.clear();
44694495

44704496
this.#globalActiveAssetSubscriptions.forEach((sub, symbol) => {
44714497
sub.unsubscribe().catch((error: Error) => {

packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3103,6 +3103,119 @@ describe('HyperLiquidSubscriptionService', () => {
31033103

31043104
unsubscribe();
31053105
});
3106+
3107+
it('does not let a slower assetCtxs batch update overwrite a price already covered by fastAssetCtxs', async () => {
3108+
let fastAssetCtxsCallback: ((data: any) => void) | undefined;
3109+
let assetCtxsCallback: ((data: any) => void) | undefined;
3110+
3111+
service.setDexMetaCache('', { universe: [{ name: 'BTC' }] } as any);
3112+
3113+
mockSubscriptionClient.fastAssetCtxs.mockImplementation(
3114+
(callback: any) => {
3115+
fastAssetCtxsCallback = callback;
3116+
return Promise.resolve({
3117+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3118+
});
3119+
},
3120+
);
3121+
3122+
mockSubscriptionClient.assetCtxs.mockImplementation(
3123+
(_params: any, callback: any) => {
3124+
assetCtxsCallback = callback;
3125+
return Promise.resolve({
3126+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3127+
});
3128+
},
3129+
);
3130+
3131+
const listCallback = jest.fn();
3132+
const unsubscribe = await service.subscribeToPrices({
3133+
symbols: ['BTC'],
3134+
callback: listCallback,
3135+
});
3136+
3137+
await jest.runAllTimersAsync();
3138+
3139+
// fastAssetCtxs establishes the authoritative price for BTC
3140+
fastAssetCtxsCallback?.({ BTC: { midPx: '52000' } });
3141+
await jest.runAllTimersAsync();
3142+
3143+
listCallback.mockClear();
3144+
3145+
// A slower assetCtxs batch tick fires for BTC with a different price.
3146+
// It should not overwrite the fresher fastAssetCtxs price.
3147+
assetCtxsCallback?.({
3148+
ctxs: [
3149+
{
3150+
prevDayPx: '49000',
3151+
funding: '0.01',
3152+
openInterest: '1000000',
3153+
dayNtlVlm: '50000000',
3154+
oraclePx: '50100',
3155+
midPx: '50200',
3156+
},
3157+
],
3158+
});
3159+
await jest.runAllTimersAsync();
3160+
3161+
expect(listCallback).not.toHaveBeenCalledWith([
3162+
expect.objectContaining({ symbol: 'BTC', price: '50200' }),
3163+
]);
3164+
3165+
unsubscribe();
3166+
});
3167+
3168+
it('lets assetCtxs update the price for a symbol not covered by fastAssetCtxs (e.g. a HIP-3 dex:symbol asset)', async () => {
3169+
let assetCtxsCallback: ((data: any) => void) | undefined;
3170+
3171+
jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({
3172+
symbol,
3173+
dex: symbol === 'xyz:STOCK1' ? 'xyz' : null,
3174+
}));
3175+
3176+
service.setDexMetaCache('xyz', {
3177+
universe: [{ name: 'xyz:STOCK1' }],
3178+
} as any);
3179+
3180+
mockSubscriptionClient.assetCtxs.mockImplementation(
3181+
(_params: any, callback: any) => {
3182+
assetCtxsCallback = callback;
3183+
return Promise.resolve({
3184+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3185+
});
3186+
},
3187+
);
3188+
3189+
const listCallback = jest.fn();
3190+
const unsubscribe = await service.subscribeToPrices({
3191+
symbols: ['xyz:STOCK1'],
3192+
callback: listCallback,
3193+
});
3194+
3195+
await jest.runAllTimersAsync();
3196+
3197+
listCallback.mockClear();
3198+
3199+
assetCtxsCallback?.({
3200+
ctxs: [
3201+
{
3202+
prevDayPx: '9',
3203+
funding: '0.01',
3204+
openInterest: '1000',
3205+
dayNtlVlm: '5000',
3206+
oraclePx: '10',
3207+
midPx: '10.5',
3208+
},
3209+
],
3210+
});
3211+
await jest.runAllTimersAsync();
3212+
3213+
expect(listCallback).toHaveBeenCalledWith([
3214+
expect.objectContaining({ symbol: 'xyz:STOCK1', price: '10.5' }),
3215+
]);
3216+
3217+
unsubscribe();
3218+
});
31063219
});
31073220

31083221
describe('Market tradability (isTradable)', () => {

packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,45 @@ describe('HyperLiquidSubscriptionService', () => {
792792

793793
unsubscribe();
794794
});
795+
796+
it('skips a coin with a null midPx and no markPx in a fastAssetCtxs update without throwing', async () => {
797+
const btcCallback = jest.fn();
798+
const ethCallback = jest.fn();
799+
800+
const unsubscribeBtc = await service.subscribeToPrices({
801+
symbols: ['BTC'],
802+
callback: btcCallback,
803+
});
804+
const unsubscribeEth = await service.subscribeToPrices({
805+
symbols: ['ETH'],
806+
callback: ethCallback,
807+
});
808+
809+
await jest.runAllTimersAsync();
810+
btcCallback.mockClear();
811+
ethCallback.mockClear();
812+
813+
const fastAssetCtxsCallback =
814+
mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0];
815+
816+
// BTC has a null midPx (and no markPx), which the SDK types allow at
817+
// runtime; ETH is a valid update (with a price change from the allMids
818+
// baseline of 3000) in the same payload.
819+
expect(() =>
820+
fastAssetCtxsCallback({
821+
BTC: { midPx: null },
822+
ETH: { midPx: '3100' },
823+
}),
824+
).not.toThrow();
825+
826+
expect(btcCallback).not.toHaveBeenCalled();
827+
expect(ethCallback).toHaveBeenCalledWith([
828+
expect.objectContaining({ symbol: 'ETH', price: '3100' }),
829+
]);
830+
831+
unsubscribeBtc();
832+
unsubscribeEth();
833+
});
795834
});
796835

797836
describe('Position Subscriptions', () => {

0 commit comments

Comments
 (0)