Skip to content

Commit 11205ef

Browse files
committed
fix(perps): create a private userFills subscription for account subscribers
1 parent 4f3b264 commit 11205ef

2 files changed

Lines changed: 356 additions & 0 deletions

File tree

app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3839,6 +3839,214 @@ describe('HyperLiquidSubscriptionService', () => {
38393839

38403840
unsubscribe();
38413841
});
3842+
3843+
it('refreshes spot-backed availableToTradeBalance after streaming fills', async () => {
3844+
mockSpotClearinghouseState
3845+
.mockResolvedValueOnce({
3846+
balances: [{ coin: 'USDC', total: '100', hold: '0' }],
3847+
})
3848+
.mockResolvedValueOnce({
3849+
balances: [{ coin: 'USDC', total: '100', hold: '3' }],
3850+
});
3851+
3852+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
3853+
availableBalance: '50',
3854+
availableToTradeBalance: '50',
3855+
totalBalance: '200',
3856+
marginUsed: '10',
3857+
unrealizedPnl: '5',
3858+
returnOnEquity: '0.05',
3859+
}));
3860+
3861+
let userFillsCallback: ((data: any) => void) | undefined;
3862+
mockSubscriptionClient.userFills.mockImplementation(
3863+
(_params: any, callback: any) => {
3864+
userFillsCallback = callback;
3865+
return Promise.resolve({
3866+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3867+
});
3868+
},
3869+
);
3870+
3871+
const singleDexService = new HyperLiquidSubscriptionService(
3872+
mockClientService,
3873+
mockWalletService,
3874+
mockDeps,
3875+
false,
3876+
);
3877+
3878+
const mockCallback = jest.fn();
3879+
const unsubscribe = singleDexService.subscribeToAccount({
3880+
callback: mockCallback,
3881+
});
3882+
3883+
await jest.runAllTimersAsync();
3884+
3885+
expect(mockCallback).toHaveBeenCalled();
3886+
expect(mockCallback.mock.calls.at(-1)[0].availableToTradeBalance).toBe(
3887+
'150',
3888+
);
3889+
3890+
mockCallback.mockClear();
3891+
expect(userFillsCallback).toBeDefined();
3892+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3893+
userFillsCallback!({
3894+
isSnapshot: false,
3895+
fills: [{ oid: 12345, coin: 'BTC', side: 'B', sz: '0.1', px: '50000' }],
3896+
});
3897+
3898+
await jest.advanceTimersByTimeAsync(250);
3899+
await jest.runAllTimersAsync();
3900+
3901+
expect(mockSpotClearinghouseState).toHaveBeenCalledTimes(2);
3902+
expect(mockCallback).toHaveBeenCalled();
3903+
expect(mockCallback.mock.calls.at(-1)[0].availableToTradeBalance).toBe(
3904+
'147',
3905+
);
3906+
3907+
unsubscribe();
3908+
});
3909+
3910+
it('does not refresh spot state on userFills snapshot events', async () => {
3911+
mockSpotClearinghouseState.mockResolvedValue({
3912+
balances: [{ coin: 'USDC', total: '100', hold: '0' }],
3913+
});
3914+
3915+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
3916+
availableBalance: '50',
3917+
availableToTradeBalance: '50',
3918+
totalBalance: '200',
3919+
marginUsed: '10',
3920+
unrealizedPnl: '5',
3921+
returnOnEquity: '0.05',
3922+
}));
3923+
3924+
let userFillsCallback: ((data: any) => void) | undefined;
3925+
mockSubscriptionClient.userFills.mockImplementation(
3926+
(_params: any, callback: any) => {
3927+
userFillsCallback = callback;
3928+
return Promise.resolve({
3929+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3930+
});
3931+
},
3932+
);
3933+
3934+
const singleDexService = new HyperLiquidSubscriptionService(
3935+
mockClientService,
3936+
mockWalletService,
3937+
mockDeps,
3938+
false,
3939+
);
3940+
3941+
const unsubscribe = singleDexService.subscribeToAccount({
3942+
callback: jest.fn(),
3943+
});
3944+
3945+
await jest.runAllTimersAsync();
3946+
3947+
expect(userFillsCallback).toBeDefined();
3948+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
3949+
userFillsCallback!({
3950+
isSnapshot: true,
3951+
fills: [{ oid: 12345, coin: 'BTC', side: 'B', sz: '0.1', px: '50000' }],
3952+
});
3953+
3954+
await jest.advanceTimersByTimeAsync(250);
3955+
await jest.runAllTimersAsync();
3956+
3957+
expect(mockSpotClearinghouseState).toHaveBeenCalledTimes(1);
3958+
3959+
unsubscribe();
3960+
});
3961+
3962+
it('coalesces multiple streaming fills into one spot refresh', async () => {
3963+
mockSpotClearinghouseState
3964+
.mockResolvedValueOnce({
3965+
balances: [{ coin: 'USDC', total: '100', hold: '0' }],
3966+
})
3967+
.mockResolvedValueOnce({
3968+
balances: [{ coin: 'USDC', total: '100', hold: '3' }],
3969+
});
3970+
3971+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
3972+
availableBalance: '50',
3973+
availableToTradeBalance: '50',
3974+
totalBalance: '200',
3975+
marginUsed: '10',
3976+
unrealizedPnl: '5',
3977+
returnOnEquity: '0.05',
3978+
}));
3979+
3980+
let userFillsCallback: ((data: any) => void) | undefined;
3981+
mockSubscriptionClient.userFills.mockImplementation(
3982+
(_params: any, callback: any) => {
3983+
userFillsCallback = callback;
3984+
return Promise.resolve({
3985+
unsubscribe: jest.fn().mockResolvedValue(undefined),
3986+
});
3987+
},
3988+
);
3989+
3990+
const singleDexService = new HyperLiquidSubscriptionService(
3991+
mockClientService,
3992+
mockWalletService,
3993+
mockDeps,
3994+
false,
3995+
);
3996+
3997+
const unsubscribe = singleDexService.subscribeToAccount({
3998+
callback: jest.fn(),
3999+
});
4000+
4001+
await jest.runAllTimersAsync();
4002+
4003+
expect(userFillsCallback).toBeDefined();
4004+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4005+
userFillsCallback!({
4006+
isSnapshot: false,
4007+
fills: [{ oid: 12345, coin: 'BTC', side: 'B', sz: '0.1', px: '50000' }],
4008+
});
4009+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4010+
userFillsCallback!({
4011+
isSnapshot: false,
4012+
fills: [{ oid: 12346, coin: 'BTC', side: 'S', sz: '0.1', px: '50010' }],
4013+
});
4014+
4015+
await jest.advanceTimersByTimeAsync(250);
4016+
await jest.runAllTimersAsync();
4017+
4018+
expect(mockSpotClearinghouseState).toHaveBeenCalledTimes(2);
4019+
4020+
unsubscribe();
4021+
});
4022+
4023+
it('cleans up the internal spot refresh fill subscription when account subscribers end', async () => {
4024+
const accountSpotRefreshSubscription = {
4025+
unsubscribe: jest.fn().mockResolvedValue(undefined),
4026+
};
4027+
4028+
mockSubscriptionClient.userFills.mockImplementation(
4029+
(_params: any, _callback: any) =>
4030+
Promise.resolve(accountSpotRefreshSubscription),
4031+
);
4032+
4033+
const singleDexService = new HyperLiquidSubscriptionService(
4034+
mockClientService,
4035+
mockWalletService,
4036+
mockDeps,
4037+
false,
4038+
);
4039+
4040+
const unsubscribe = singleDexService.subscribeToAccount({
4041+
callback: jest.fn(),
4042+
});
4043+
4044+
await jest.runAllTimersAsync();
4045+
4046+
unsubscribe();
4047+
4048+
expect(accountSpotRefreshSubscription.unsubscribe).toHaveBeenCalled();
4049+
});
38424050
});
38434051

38444052
describe('aggregateAccountStates - returnOnEquity calculation', () => {

0 commit comments

Comments
 (0)