Skip to content

Commit 8cfa941

Browse files
authored
fix: [Mobile] Perps KEYRING_LOCKED error in HyperLiquidWalletService (#30077)
## **Description** Fixes Perps `KEYRING_LOCKED` handling when HyperLiquid wraps wallet signing failures. The provider now walks the `Error.cause` chain so wrapped keyring-lock errors use the retry-later path instead of being reported as generic setup failures. Follow-up considered: preflight-gating Perps signing setup until the keyring is unlocked/available. That may avoid entering SDK signing paths that cannot succeed, but it changes initialization timing and retry behavior. This PR keeps the existing retry-later flow and fixes the concrete error-boundary bug: HyperLiquid SDK wraps `KEYRING_LOCKED` as the error `cause`, so provider catches must inspect the cause chain. We will monitor Sentry after release; if locked signing events remain meaningful, the next step is a keyring-availability gate before signing-dependent setup. ## **Changelog** CHANGELOG entry: Fixed a Perps setup error that could report locked-keyring retries as failures ## **Related issues** Fixes: [TAT-3176](https://consensyssoftware.atlassian.net/browse/TAT-3176) ## **Manual testing steps** ```gherkin Feature: Perps keyring lock retry Scenario: wrapped keyring lock is handled as retry-later Given HyperLiquid setup receives a wrapped signing error with cause.message KEYRING_LOCKED When the provider classifies the setup error Then the provider uses the keyring-locked retry-later branch And the generic Sentry failure branch is not used ``` ## **Screenshots/Recordings** No visual evidence selected for publication. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ## **Validation Recipe** <details> <summary>recipe.json</summary> ```json { "pr": "", "title": "TAT-3176 wrapped KEYRING_LOCKED classifier", "jira": "TAT-3176", "acceptance_criteria": [ "Wrapped KEYRING_LOCKED errors whose cause.message is KEYRING_LOCKED are classified as keyring-locked retry-later errors instead of falling through to generic logger.error/Sentry, failed AccountSetup analytics, or failure caches." ], "validate": { "workflow": { "entry": "ac1-assert-wrapped-keyring-locked", "nodes": { "ac1-assert-wrapped-keyring-locked": { "action": "eval_sync", "expression": "(function(){var mods=globalThis.__r.getModules();var moduleId=null;mods.forEach(function(v,k){if(v&&v.verboseName==='app/controllers/perps/utils/errorUtils.ts'){moduleId=k;}});var errorUtils=globalThis.__r(moduleId);var cause=new Error('KEYRING_LOCKED');var wrapped=new Error('Failed to sign typed data with viem wallet');wrapped.cause=cause;return JSON.stringify({moduleFound:moduleId!==null,classified:errorUtils.isKeyringLockedError(wrapped)});})()", "assert": { "operator": "eq", "field": "classified", "value": true }, "next": "done" }, "done": { "action": "end", "status": "pass" } } } } } ``` </details> ## **Recipe Workflow** <details> <summary>workflow.mmd</summary> ```mermaid flowchart TD ac1["ac1-assert-wrapped-keyring-locked"] done["done"] ac1 --> done ``` </details> [TAT-3176]: https://consensyssoftware.atlassian.net/browse/TAT-3176?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes error classification for keyring-locked signing failures, affecting retry/caching and whether failures get logged/reported; mistakes here could cause repeated prompts or suppressed retries in trading setup. > > **Overview** > Prevents HyperLiquid SDK-wrapped signing failures from being treated as generic setup errors by introducing `isKeyringLockedError()` (walks the `Error.cause` chain) and using it in HyperLiquid unified account enablement, builder-fee approval, and referral setup. > > Adds test coverage ensuring wrapped `KEYRING_LOCKED` errors do **not** populate failure caches (`TradingReadinessCache`/`PerpsSigningCache`) or trigger error logging, while still releasing in-flight locks and allowing the order/setup flow to proceed. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4fdd74f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d4d4dfa commit 8cfa941

4 files changed

Lines changed: 191 additions & 10 deletions

File tree

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5969,6 +5969,52 @@ describe('HyperLiquidProvider', () => {
59695969
expect(result.orderId).toBeDefined();
59705970
});
59715971

5972+
it('leaves builder fee cache empty when wrapped KEYRING_LOCKED is thrown', async () => {
5973+
const wrappedKeyringLockedError = Object.assign(
5974+
new Error('Failed to sign typed data with viem wallet'),
5975+
{ cause: new Error('KEYRING_LOCKED') },
5976+
);
5977+
const mockCompleteInFlight = jest.fn();
5978+
(
5979+
TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>
5980+
).setInFlight.mockReturnValue(mockCompleteInFlight);
5981+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
5982+
createMockInfoClient({
5983+
maxBuilderFee: jest.fn().mockResolvedValue(0),
5984+
referral: jest.fn().mockResolvedValue({
5985+
referrerState: {
5986+
stage: 'not_ready',
5987+
data: null,
5988+
},
5989+
}),
5990+
}),
5991+
);
5992+
mockClientService.getExchangeClient = jest.fn().mockReturnValue(
5993+
createMockExchangeClient({
5994+
approveBuilderFee: jest
5995+
.fn()
5996+
.mockRejectedValue(wrappedKeyringLockedError),
5997+
}),
5998+
);
5999+
6000+
const orderParams: OrderParams = {
6001+
symbol: 'BTC',
6002+
isBuy: true,
6003+
size: '0.1',
6004+
orderType: 'market',
6005+
currentPrice: 50000,
6006+
};
6007+
6008+
const result = await provider.placeOrder(orderParams);
6009+
6010+
expect(result.success).toBe(true);
6011+
expect(
6012+
(TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>)
6013+
.setBuilderFee,
6014+
).not.toHaveBeenCalled();
6015+
expect(mockCompleteInFlight).toHaveBeenCalled();
6016+
});
6017+
59726018
it('handles referral code setup failure (non-blocking)', async () => {
59736019
// Mock builder fee already approved
59746020
mockClientService.getInfoClient = jest
@@ -6007,6 +6053,53 @@ describe('HyperLiquidProvider', () => {
60076053
expect(result.orderId).toBeDefined();
60086054
});
60096055

6056+
it('leaves referral cache empty when wrapped KEYRING_LOCKED is thrown', async () => {
6057+
const wrappedKeyringLockedError = Object.assign(
6058+
new Error('Failed to sign typed data with viem wallet'),
6059+
{ cause: new Error('KEYRING_LOCKED') },
6060+
);
6061+
const mockCompleteInFlight = jest.fn();
6062+
(
6063+
TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>
6064+
).setInFlight.mockReturnValue(mockCompleteInFlight);
6065+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
6066+
createMockInfoClient({
6067+
maxBuilderFee: jest.fn().mockResolvedValue(1),
6068+
referral: jest.fn().mockResolvedValue({
6069+
referrerState: {
6070+
stage: 'ready',
6071+
data: { code: REFERRAL_CONFIG.MainnetCode },
6072+
},
6073+
referredBy: null,
6074+
}),
6075+
}),
6076+
);
6077+
mockClientService.getExchangeClient = jest.fn().mockReturnValue(
6078+
createMockExchangeClient({
6079+
setReferrer: jest.fn().mockRejectedValue(wrappedKeyringLockedError),
6080+
}),
6081+
);
6082+
6083+
const orderParams: OrderParams = {
6084+
symbol: 'BTC',
6085+
isBuy: true,
6086+
size: '0.1',
6087+
orderType: 'market',
6088+
currentPrice: 50000,
6089+
};
6090+
6091+
const result = await provider.placeOrder(orderParams);
6092+
await Promise.resolve();
6093+
await Promise.resolve();
6094+
6095+
expect(result.success).toBe(true);
6096+
expect(
6097+
(TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>)
6098+
.setReferral,
6099+
).not.toHaveBeenCalled();
6100+
expect(mockCompleteInFlight).toHaveBeenCalled();
6101+
});
6102+
60106103
it('skips referral setup when referral code is not ready', async () => {
60116104
// Mock referral code not ready
60126105
mockClientService.getInfoClient = jest.fn().mockReturnValue(
@@ -9325,6 +9418,41 @@ describe('HyperLiquidProvider', () => {
93259418
expect(mockCompleteInFlight).toHaveBeenCalled();
93269419
});
93279420

9421+
it('does NOT cache or log to Sentry when a wrapped KEYRING_LOCKED error is thrown', async () => {
9422+
// Arrange
9423+
const wrappedKeyringLockedError = Object.assign(
9424+
new Error('Failed to sign typed data with viem wallet'),
9425+
{ cause: new Error('KEYRING_LOCKED') },
9426+
);
9427+
const mockExchangeClient = createMockExchangeClient();
9428+
mockExchangeClient.agentSetAbstraction = jest
9429+
.fn()
9430+
.mockRejectedValue(wrappedKeyringLockedError);
9431+
const mockCompleteInFlight = jest.fn();
9432+
(
9433+
TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>
9434+
).setInFlight.mockReturnValue(mockCompleteInFlight);
9435+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
9436+
createMockInfoClient({
9437+
userAbstraction: jest.fn().mockResolvedValue('default'),
9438+
}),
9439+
);
9440+
mockClientService.getExchangeClient = jest
9441+
.fn()
9442+
.mockReturnValue(mockExchangeClient);
9443+
9444+
// Act
9445+
await provider.getMarketDataWithPrices();
9446+
9447+
// Assert
9448+
expect(
9449+
(TradingReadinessCache as jest.Mocked<typeof TradingReadinessCache>)
9450+
.set,
9451+
).not.toHaveBeenCalled();
9452+
expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
9453+
expect(mockCompleteInFlight).toHaveBeenCalled();
9454+
});
9455+
93289456
it('does NOT cache failure when userAbstraction read itself rejects', async () => {
93299457
// Read-only userAbstraction lookup failures (transient HL outage /
93309458
// network) must not block all future migration attempts for the rest

app/controllers/perps/providers/HyperLiquidProvider.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ import {
128128
addSpotBalanceToAccountState,
129129
aggregateAccountStates,
130130
} from '../utils/accountUtils';
131-
import { ensureError } from '../utils/errorUtils';
131+
import { ensureError, isKeyringLockedError } from '../utils/errorUtils';
132132
import {
133133
adaptAccountStateFromSDK,
134134
adaptHyperLiquidLedgerUpdateToUserHistoryItem,
@@ -814,8 +814,9 @@ export class HyperLiquidProvider implements PerpsProvider {
814814
);
815815
completeInFlight();
816816
} catch (error) {
817-
// If keyring is locked, don't cache so it retries when unlocked
818-
if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) {
817+
// HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED
818+
// in `cause`, so classify the full chain and leave retry caches empty.
819+
if (isKeyringLockedError(error)) {
819820
this.#deps.debugLogger.log(
820821
'[ensureUnifiedAccountEnabled] Keyring locked, will retry later',
821822
);
@@ -2650,8 +2651,9 @@ export class HyperLiquidProvider implements PerpsProvider {
26502651
}
26512652
completeInFlight();
26522653
} catch (error) {
2653-
// If keyring is locked, don't cache so it retries when unlocked
2654-
if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) {
2654+
// HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED
2655+
// in `cause`, so classify the full chain and leave retry caches empty.
2656+
if (isKeyringLockedError(error)) {
26552657
this.#deps.debugLogger.log(
26562658
'[ensureBuilderFeeApproval] Keyring locked, will retry later',
26572659
);
@@ -8468,8 +8470,9 @@ export class HyperLiquidProvider implements PerpsProvider {
84688470
}
84698471
completeInFlight();
84708472
} catch (error) {
8471-
// If keyring is locked, don't cache so it retries when unlocked
8472-
if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) {
8473+
// HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED
8474+
// in `cause`, so classify the full chain and leave retry caches empty.
8475+
if (isKeyringLockedError(error)) {
84738476
this.#deps.debugLogger.log(
84748477
'[ensureReferralSet] Keyring locked, will retry later',
84758478
);

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isAbortError, ensureError } from './errorUtils';
1+
import { isAbortError, ensureError, isKeyringLockedError } from './errorUtils';
22

33
describe('errorUtils', () => {
44
describe('isAbortError', () => {
@@ -71,4 +71,28 @@ describe('errorUtils', () => {
7171
expect(result.message).toContain('Unknown error');
7272
});
7373
});
74+
75+
describe('isKeyringLockedError', () => {
76+
it('returns true for direct KEYRING_LOCKED errors', () => {
77+
const error = new Error('KEYRING_LOCKED');
78+
79+
expect(isKeyringLockedError(error)).toBe(true);
80+
});
81+
82+
it('returns true for wrapped KEYRING_LOCKED causes', () => {
83+
const error = Object.assign(
84+
new Error('Failed to sign typed data with viem wallet'),
85+
{ cause: new Error('KEYRING_LOCKED') },
86+
);
87+
88+
expect(isKeyringLockedError(error)).toBe(true);
89+
});
90+
91+
it('returns false for non-keyring errors with cyclic causes', () => {
92+
const error = new Error('Network error') as Error & { cause?: unknown };
93+
error.cause = error;
94+
95+
expect(isKeyringLockedError(error)).toBe(false);
96+
});
97+
});
7498
});

app/controllers/perps/utils/errorUtils.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/**
2-
* Utility functions for error handling across the application.
3-
* These are general-purpose utilities, not domain-specific.
2+
* Utility functions for error handling across Perps controller code.
3+
* Includes generic error helpers and Perps error classification helpers.
44
*/
55
import { hasProperty } from '@metamask/utils';
66

7+
import { PERPS_ERROR_CODES } from '../perpsErrorCodes';
8+
79
/**
810
* Detects expected cancellation/abort errors that should not be reported to Sentry.
911
* These occur during normal navigation or view teardown when in-flight fetch requests
@@ -23,6 +25,30 @@ export function isAbortError(error: unknown): boolean {
2325
return false;
2426
}
2527

28+
/**
29+
* Detects keyring-locked errors, including SDK-wrapped errors that preserve the
30+
* original error in `cause`.
31+
*
32+
* @param error - The error to check.
33+
* @returns True if any error in the cause chain is KEYRING_LOCKED.
34+
*/
35+
export function isKeyringLockedError(error: unknown): boolean {
36+
let current: unknown = error;
37+
const seen = new Set<unknown>();
38+
39+
while (current instanceof Error && !seen.has(current)) {
40+
seen.add(current);
41+
42+
if (current.message === PERPS_ERROR_CODES.KEYRING_LOCKED) {
43+
return true;
44+
}
45+
46+
current = (current as { cause?: unknown }).cause;
47+
}
48+
49+
return false;
50+
}
51+
2652
/**
2753
* Ensures we have a proper Error object for logging.
2854
* Converts unknown/string errors to proper Error instances.

0 commit comments

Comments
 (0)