Skip to content

Commit e116ac1

Browse files
committed
chore: init fix, catch case where account is not yet setup
1 parent 1de00bb commit e116ac1

7 files changed

Lines changed: 320 additions & 5 deletions

File tree

app/controllers/perps/constants/eventNames.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,10 @@ export const PERPS_EVENT_VALUE = {
360360
SUCCESS: 'success',
361361
ALREADY_ENABLED: 'already_enabled',
362362
MIGRATION_REQUIRED: 'migration_required',
363+
// Emitted when a migration attempt is skipped because it is not applicable
364+
// (e.g. the user has no HyperLiquid account yet — nothing to migrate).
365+
// Distinguishes expected no-ops from real failures in dashboards.
366+
NOT_APPLICABLE: 'not_applicable',
363367
},
364368
SCREEN_TYPE: {
365369
MARKETS: 'markets',

app/controllers/perps/providers/HyperLiquidProvider.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9354,6 +9354,163 @@ describe('HyperLiquidProvider', () => {
93549354
expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled();
93559355
});
93569356

9357+
// ─────────────────────────────────────────────────
9358+
// "User or API Wallet does not exist" — unfunded wallet no-op
9359+
//
9360+
// HL returns this error when a wallet has never deposited funds.
9361+
// The migration should be silently recorded as not_applicable
9362+
// and must NOT fire a Sentry error or a failed analytics event.
9363+
// ─────────────────────────────────────────────────
9364+
9365+
it('silences the "User or API Wallet does not exist" error from agentSetAbstraction (default → unified)', async () => {
9366+
// Arrange: HL reports the wallet has no account
9367+
const userNotFoundError = new Error(
9368+
'User or API Wallet 0xabc does not exist.',
9369+
);
9370+
const mockExchangeClient = createMockExchangeClient();
9371+
mockExchangeClient.agentSetAbstraction = jest
9372+
.fn()
9373+
.mockRejectedValue(userNotFoundError);
9374+
mockClientService.getExchangeClient = jest
9375+
.fn()
9376+
.mockReturnValue(mockExchangeClient);
9377+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
9378+
createMockInfoClient({
9379+
userAbstraction: jest.fn().mockResolvedValue('default'),
9380+
}),
9381+
);
9382+
9383+
// Act — must resolve, not throw
9384+
await expect(provider.getMarketDataWithPrices()).resolves.not.toThrow();
9385+
9386+
// Cache written with reason: 'no_hl_account' so we stop retrying
9387+
expect(
9388+
(TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>)
9389+
.set,
9390+
).toHaveBeenCalledWith('mainnet', USER_ADDRESS, {
9391+
attempted: true,
9392+
enabled: false,
9393+
reason: 'no_hl_account',
9394+
});
9395+
9396+
// Analytics: one migration_required event followed by one not_applicable
9397+
const trackCalls = (
9398+
mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock
9399+
).mock.calls.filter((call) => call[0] === 'Perp Account Setup');
9400+
const notApplicableCall = trackCalls.find(
9401+
(call) => call[1]?.status === 'not_applicable',
9402+
);
9403+
expect(notApplicableCall).toBeDefined();
9404+
expect(notApplicableCall[1]).toEqual(
9405+
expect.objectContaining({
9406+
previous_abstraction_mode: 'default',
9407+
abstraction_mode: 'unifiedAccount',
9408+
status: 'not_applicable',
9409+
error_message: 'no_hl_account',
9410+
}),
9411+
);
9412+
9413+
// Sentry NOT called — this is not a real failure
9414+
expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
9415+
});
9416+
9417+
it('silences the "User or API Wallet does not exist" error from userSetAbstraction (dexAbstraction → unified)', async () => {
9418+
// Arrange: dexAbstraction user with no HL account
9419+
const userNotFoundError = new Error(
9420+
'User or API Wallet 0xdef does not exist.',
9421+
);
9422+
const mockExchangeClient = createMockExchangeClient();
9423+
mockExchangeClient.userSetAbstraction = jest
9424+
.fn()
9425+
.mockRejectedValue(userNotFoundError);
9426+
mockClientService.getExchangeClient = jest
9427+
.fn()
9428+
.mockReturnValue(mockExchangeClient);
9429+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
9430+
createMockInfoClient({
9431+
userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'),
9432+
}),
9433+
);
9434+
9435+
// Act — init path for software wallet (allowUserSigning=true via
9436+
// isSelectedHardwareWallet returning false)
9437+
await expect(provider.getMarketDataWithPrices()).resolves.not.toThrow();
9438+
9439+
// Cache written with reason: 'no_hl_account' (not the dexAbstraction-
9440+
// failure path — the no_hl_account branch must win)
9441+
expect(
9442+
(TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>)
9443+
.set,
9444+
).toHaveBeenCalledWith('mainnet', USER_ADDRESS, {
9445+
attempted: true,
9446+
enabled: false,
9447+
reason: 'no_hl_account',
9448+
});
9449+
9450+
// Analytics: not_applicable, not failed
9451+
const trackCalls = (
9452+
mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock
9453+
).mock.calls.filter((call) => call[0] === 'Perp Account Setup');
9454+
const notApplicableCall = trackCalls.find(
9455+
(call) => call[1]?.status === 'not_applicable',
9456+
);
9457+
expect(notApplicableCall).toBeDefined();
9458+
expect(notApplicableCall[1]).toEqual(
9459+
expect.objectContaining({
9460+
previous_abstraction_mode: 'dexAbstraction',
9461+
abstraction_mode: 'unifiedAccount',
9462+
status: 'not_applicable',
9463+
error_message: 'no_hl_account',
9464+
}),
9465+
);
9466+
9467+
// Sentry NOT called
9468+
expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
9469+
});
9470+
9471+
it('still fires the failed analytics event and Sentry for unrelated agentSetAbstraction errors', async () => {
9472+
// Arrange: unrelated error to confirm the special case does not
9473+
// swallow legitimate failures
9474+
const unrelatedError = new Error('Insufficient margin');
9475+
const mockExchangeClient = createMockExchangeClient();
9476+
mockExchangeClient.agentSetAbstraction = jest
9477+
.fn()
9478+
.mockRejectedValue(unrelatedError);
9479+
mockClientService.getExchangeClient = jest
9480+
.fn()
9481+
.mockReturnValue(mockExchangeClient);
9482+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
9483+
createMockInfoClient({
9484+
userAbstraction: jest.fn().mockResolvedValue('default'),
9485+
}),
9486+
);
9487+
9488+
// Act — still resolves (error is caught internally)
9489+
await expect(provider.getMarketDataWithPrices()).resolves.not.toThrow();
9490+
9491+
// Analytics: should have a failed event, no not_applicable
9492+
const trackCalls = (
9493+
mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock
9494+
).mock.calls.filter((call) => call[0] === 'Perp Account Setup');
9495+
const failedCall = trackCalls.find(
9496+
(call) => call[1]?.status === 'failed',
9497+
);
9498+
expect(failedCall).toBeDefined();
9499+
expect(failedCall[1]).toEqual(
9500+
expect.objectContaining({
9501+
status: 'failed',
9502+
error_message: 'Insufficient margin',
9503+
}),
9504+
);
9505+
const notApplicableCall = trackCalls.find(
9506+
(call) => call[1]?.status === 'not_applicable',
9507+
);
9508+
expect(notApplicableCall).toBeUndefined();
9509+
9510+
// Sentry IS called
9511+
expect(mockPlatformDependencies.logger.error).toHaveBeenCalled();
9512+
});
9513+
93579514
// ─────────────────────────────────────────────────
93589515
// Network key (mainnet vs testnet)
93599516
// ─────────────────────────────────────────────────

app/controllers/perps/providers/HyperLiquidProvider.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ import {
128128
addSpotBalanceToAccountState,
129129
aggregateAccountStates,
130130
} from '../utils/accountUtils';
131-
import { ensureError } from '../utils/errorUtils';
131+
import {
132+
ensureError,
133+
isHyperLiquidUserNotFoundError,
134+
} from '../utils/errorUtils';
132135
import {
133136
adaptAccountStateFromSDK,
134137
adaptHyperLiquidLedgerUpdateToUserHistoryItem,
@@ -824,6 +827,37 @@ export class HyperLiquidProvider implements PerpsProvider {
824827
return;
825828
}
826829

830+
// User has no HL account yet — nothing to migrate. HL surfaces this as
831+
// "User or API Wallet 0x… does not exist." for both the agent-key and
832+
// user-signed paths. This is benign: the user has no funds on HL, and
833+
// the next migration attempt after their first deposit will succeed.
834+
// We cache it so we don't re-fire the doomed call (and the failed
835+
// analytics/Sentry events) on every Perps section open. The cache resets
836+
// on app restart, which is sufficient coverage for users who deposit and
837+
// trade within the same session.
838+
if (isHyperLiquidUserNotFoundError(error)) {
839+
this.#deps.debugLogger.log(
840+
'[ensureUnifiedAccountEnabled] User has no HL account yet, skipping migration',
841+
{ user: userAddress, network },
842+
);
843+
TradingReadinessCache.set(network, userAddress, {
844+
attempted: true,
845+
enabled: false,
846+
reason: 'no_hl_account',
847+
});
848+
this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, {
849+
...(currentMode && {
850+
[PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode,
851+
[PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE,
852+
}),
853+
[PERPS_EVENT_PROPERTY.STATUS]:
854+
PERPS_EVENT_VALUE.STATUS.NOT_APPLICABLE,
855+
[PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: 'no_hl_account',
856+
});
857+
completeInFlight();
858+
return;
859+
}
860+
827861
// Cache failure ONLY for the user-prompted path
828862
// (`dexAbstraction → unifiedAccount` via `userSetAbstraction`). The
829863
// rationale for caching is "don't re-prompt a user who already saw the

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,43 @@ describe('TradingReadinessCache / PerpsSigningCache', () => {
118118
false,
119119
);
120120
});
121+
122+
it('stores and returns a reason discriminator', () => {
123+
TradingReadinessCache.set(network, userAddress, {
124+
attempted: true,
125+
enabled: false,
126+
reason: 'no_hl_account',
127+
});
128+
129+
const result = TradingReadinessCache.get(network, userAddress);
130+
expect(result?.reason).toBe('no_hl_account');
131+
expect(result?.attempted).toBe(true);
132+
expect(result?.enabled).toBe(false);
133+
});
134+
135+
it('returns undefined reason when set without one', () => {
136+
TradingReadinessCache.set(network, userAddress, {
137+
attempted: true,
138+
enabled: false,
139+
});
140+
141+
const result = TradingReadinessCache.get(network, userAddress);
142+
expect(result?.reason).toBeUndefined();
143+
});
144+
145+
it('clears reason when clearUnifiedAccount is called', () => {
146+
TradingReadinessCache.set(network, userAddress, {
147+
attempted: true,
148+
enabled: false,
149+
reason: 'no_hl_account',
150+
});
151+
152+
TradingReadinessCache.clearUnifiedAccount(network, userAddress);
153+
154+
const result = TradingReadinessCache.get(network, userAddress);
155+
expect(result?.reason).toBeUndefined();
156+
expect(result?.attempted).toBe(false);
157+
});
121158
});
122159
});
123160

app/controllers/perps/services/TradingReadinessCache.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
type SigningOperationState = {
2626
attempted: boolean; // Whether we've attempted this operation
2727
success: boolean; // Whether it succeeded (only valid if attempted=true)
28+
reason?: 'no_hl_account' | 'user_rejected' | 'transient'; // optional discriminator
2829
};
2930

3031
type PerpsSigningCacheEntry = {
@@ -39,6 +40,7 @@ type TradingReadinessCacheEntry = {
3940
attempted: boolean;
4041
enabled: boolean;
4142
timestamp: number;
43+
reason?: 'no_hl_account' | 'user_rejected' | 'transient';
4244
};
4345

4446
class PerpsSigningCacheManager {
@@ -149,6 +151,7 @@ class PerpsSigningCacheManager {
149151
attempted: entry.unifiedAccount.attempted,
150152
enabled: entry.unifiedAccount.success,
151153
timestamp: entry.timestamp,
154+
reason: entry.unifiedAccount.reason,
152155
};
153156
}
154157

@@ -160,14 +163,23 @@ class PerpsSigningCacheManager {
160163
* @param data - The transaction data payload.
161164
* @param data.attempted - Whether the operation was attempted.
162165
* @param data.enabled - Whether the feature is enabled.
166+
* @param data.reason - Optional discriminator for why the operation ended in this state.
163167
*/
164168
public set(
165169
network: 'mainnet' | 'testnet',
166170
userAddress: string,
167-
data: { attempted: boolean; enabled: boolean },
171+
data: {
172+
attempted: boolean;
173+
enabled: boolean;
174+
reason?: 'no_hl_account' | 'user_rejected' | 'transient';
175+
},
168176
): void {
169177
const entry = this.#getOrCreateEntry(network, userAddress);
170-
entry.unifiedAccount = { attempted: data.attempted, success: data.enabled };
178+
entry.unifiedAccount = {
179+
attempted: data.attempted,
180+
success: data.enabled,
181+
reason: data.reason,
182+
};
171183
entry.timestamp = Date.now();
172184
}
173185

@@ -257,7 +269,11 @@ class PerpsSigningCacheManager {
257269
const key = this.#getCacheKey(network, userAddress);
258270
const entry = this.#cache.get(key);
259271
if (entry) {
260-
entry.unifiedAccount = { attempted: false, success: false };
272+
entry.unifiedAccount = {
273+
attempted: false,
274+
success: false,
275+
reason: undefined,
276+
};
261277
entry.timestamp = Date.now();
262278
}
263279
}

app/controllers/perps/utils/errorUtils.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,59 @@
1-
import { isAbortError, ensureError } from './errorUtils';
1+
import {
2+
isAbortError,
3+
ensureError,
4+
isHyperLiquidUserNotFoundError,
5+
} from './errorUtils';
26

37
describe('errorUtils', () => {
8+
describe('isHyperLiquidUserNotFoundError', () => {
9+
it('returns true for the canonical HL error message', () => {
10+
const error = new Error(
11+
'User or API Wallet 0x8e05901d9ef496a220067a0d0841a1728269fc8f does not exist.',
12+
);
13+
expect(isHyperLiquidUserNotFoundError(error)).toBe(true);
14+
});
15+
16+
it('returns true for a short address variant', () => {
17+
const error = new Error(
18+
'User or API Wallet 0x0000000000000000000000000000000000000000 does not exist.',
19+
);
20+
expect(isHyperLiquidUserNotFoundError(error)).toBe(true);
21+
});
22+
23+
it('is case-insensitive', () => {
24+
const error = new Error('user or api wallet 0xabc does not exist');
25+
expect(isHyperLiquidUserNotFoundError(error)).toBe(true);
26+
});
27+
28+
it('returns false for an unrelated API error', () => {
29+
const error = new Error('Insufficient margin');
30+
expect(isHyperLiquidUserNotFoundError(error)).toBe(false);
31+
});
32+
33+
it('returns false for a generic network error', () => {
34+
const error = new Error('Network request failed');
35+
expect(isHyperLiquidUserNotFoundError(error)).toBe(false);
36+
});
37+
38+
it('returns false for null', () => {
39+
expect(isHyperLiquidUserNotFoundError(null)).toBe(false);
40+
});
41+
42+
it('returns false for undefined', () => {
43+
expect(isHyperLiquidUserNotFoundError(undefined)).toBe(false);
44+
});
45+
46+
it('returns false for a plain string that does not match', () => {
47+
expect(isHyperLiquidUserNotFoundError('some random error')).toBe(false);
48+
});
49+
50+
it('returns false for a non-Error object', () => {
51+
expect(
52+
isHyperLiquidUserNotFoundError({ message: 'does not exist' }),
53+
).toBe(false);
54+
});
55+
});
56+
457
describe('isAbortError', () => {
558
it('returns true for Error with name AbortError', () => {
659
const error = new Error('The operation was aborted');

0 commit comments

Comments
 (0)