Skip to content

Commit 66b4d06

Browse files
authored
fix(perps): suppress "User or API Wallet does not exist" Sentry noise from unfunded wallets (#29972)
## **Description** Hyperliquid creates user accounts server-side on first USDC deposit. Until then, every user-scoped exchange write (`agentSetAbstraction`, `userSetAbstraction`, `setReferrer`) rejects with `User or API Wallet 0x... does not exist.` and the catch in `HyperLiquidProvider.#ensureUnifiedAccountEnabled` was forwarding these benign rejections to Sentry on every Perps section open. Last 14d on `7.75.1+4800`: - [METAMASK-MOBILE-4XB5](https://metamask.sentry.io/issues/METAMASK-MOBILE-4XB5) (iOS) — 316k events / 63k users - [METAMASK-MOBILE-4Q4M](https://metamask.sentry.io/issues/METAMASK-MOBILE-4Q4M) (Android) — 1.7M events / 176k users Source pinned by event Additional Context: `HyperLiquidProvider.method: ensureUnifiedAccountEnabled`. **Fix: proactive gate.** Probe `infoClient.userNonFundingLedgerUpdates` once (cheap, ~100 ms, non-throwing) before any user-scoped exchange write. The ledger is empty if and only if the wallet has never interacted with Hyperliquid — a necessary and sufficient precondition for the exchange writes to succeed. When empty, skip the migration / referral writes, fire `Perp Account Setup` with `status: not_applicable`, `error_message: no_hl_account`. Positive observations cached in `PerpsSigningCache.walletRegistered`; negative results re-probe on next entry so the gate self-heals once the user deposits. `isHyperLiquidUserNotFoundError` remains as a safety net in three catches (`ensureUnifiedAccountEnabled`, `ensureReferralSet`, `setReferralCode`) for the small race window where the probe succeeded but the write still rejected. Unrelated SDK errors keep reaching `logger.error`. ### Reproduced standalone Confirmed with a node script (not committed) that drives the SDK directly with a freshly-generated EOA. Key signals: - `exchangeClient.agentSetAbstraction({abstraction:'u'})` rejects with the exact Sentry string. - `infoClient.userAbstraction` is **not** the throw site — it returns `'default'` for fresh wallets. - `infoClient.userNonFundingLedgerUpdates` returns `[]` for fresh wallets — a clean array-length discriminator (no string parsing). ### Why this supersedes #29828 #29828 catches and classifies after the SDK rejects. This PR prevents the SDK call when we already know it will fail. | | #29828 | This PR | |---|---|---| | Exchange round-trip for unfunded wallets | Full call | Skipped | | HL-side error logs | Generated | None | | Coverage | `ensureUnifiedAccountEnabled` only | `ensureUnifiedAccountEnabled` + `ensureReferralSet` | | New throw sites | Need a new catch | Existing probe gates them | Cherry-picked from #29828: `isHyperLiquidUserNotFoundError` helper, `reason: 'no_hl_account'` discriminator, `STATUS.NOT_APPLICABLE` constant. ## **Changelog** CHANGELOG entry: null ## **Related issues** Supersedes: #29828 Sentry: METAMASK-MOBILE-4XB5, METAMASK-MOBILE-4Q4M, METAMASK-MOBILE-4KC4 ## **Manual testing steps** ```gherkin Feature: Unfunded wallets do not pollute Sentry with HL exchange-write rejections Scenario: Fresh wallet opens Perps without depositing Given the wallet has never deposited to Hyperliquid When the user opens the Perps section Then no Sentry event is captured with title "ApiRequestError: User or API Wallet ** does not exist." And one Segment "Perp Account Setup" event with status=not_applicable, error_message=no_hl_account is fired Scenario: Wallet funds during the session Given the gate previously deferred for this wallet When the user deposits USDC and re-enters Perps Then the unified account migration runs as before Scenario: Unrelated SDK error still surfaces Given the wallet is funded When agentSetAbstraction throws e.g. "Insufficient margin" Then logger.error fires as before And the Segment event has status=failed (not not_applicable) ``` Automated: ``` yarn jest \ app/controllers/perps/providers/HyperLiquidProvider.test.ts \ app/controllers/perps/services/TradingReadinessCache.test.ts \ app/controllers/perps/utils/errorUtils.test.ts \ --no-coverage # 3 suites, 395 passed, 0 failed ``` `yarn lint` clean. `tsc --noEmit` clean. ## **Screenshots/Recordings** N/A — internal observability change. Verify via Sentry dashboard delta 24–48h after release. ## **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 where applicable - [x] I've applied the right labels on the PR #### Performance checks (if applicable) - [x] I've tested on Android — N/A (no runtime UX change; one cheap extra read on Perps init for unfunded wallets) - [x] I've tested with a power user scenario — N/A - [x] I've instrumented key operations with Sentry traces for production performance metrics — N/A; removes noise rather than adds traces ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR - [ ] I confirm that this PR addresses all acceptance criteria <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches Hyperliquid account setup and referral flows and adds new caching/gating logic; mistakes could defer migration/referral for some funded users, though the probe is designed to fail open and has test coverage. > > **Overview** > Reduces Perps Sentry noise by **proactively skipping Hyperliquid exchange writes** when the wallet has no Hyperliquid account yet (no funding/ledger history), and instead tracking `Perp Account Setup` with `status: not_applicable` and `error_message: no_hl_account`. > > Adds `#isWalletOnHyperliquid` (ledger probe + positive-only cache in `PerpsSigningCache.walletRegistered`) and uses it to gate `#ensureUnifiedAccountEnabled` and referral setup; also introduces `isHyperLiquidUserNotFoundError` as a safety-net classifier so these benign rejections are debug-logged rather than sent to Sentry. > > Extends analytics constants with `STATUS.NOT_APPLICABLE`, enriches `TradingReadinessCache` entries with an optional `reason`, and adds focused unit tests covering the new gate, cache behavior, and error classification. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9e54db7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5b1913f commit 66b4d06

7 files changed

Lines changed: 654 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: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10838,4 +10838,210 @@ describe('HyperLiquidProvider', () => {
1083810838
await expect(provider.getExchangeClient()).rejects.toBe(bomb);
1083910839
});
1084010840
});
10841+
10842+
// ──────────────────────────────────────────────────────────────────────
10843+
// Proactive "user does not exist" gate
10844+
//
10845+
// Hyperliquid creates user accounts server-side on first USDC deposit.
10846+
// Before that, exchange writes (`agentSetAbstraction`, `userSetAbstraction`,
10847+
// `setReferrer`, ...) reject with
10848+
// ApiRequestError: User or API Wallet 0x... does not exist.
10849+
// and the catch in `#ensureUnifiedAccountEnabled` forwards them to Sentry.
10850+
//
10851+
// The provider probes `infoClient.clearinghouseState` first and defers
10852+
// migration when every existence signal is zero/empty. The classifier in
10853+
// the catch acts as a safety net for races (probe succeeded but write
10854+
// still rejected).
10855+
//
10856+
// Sentry source: METAMASK-MOBILE-4XB5 (iOS) / 4Q4M (Android).
10857+
// Standalone repro: scripts/repro-hl-user-not-found.mjs.
10858+
// ──────────────────────────────────────────────────────────────────────
10859+
describe('wallet-not-on-hyperliquid gate (ensureUnifiedAccountEnabled)', () => {
10860+
const USER_ADDRESS = '0x1234567890123456789012345678901234567890';
10861+
10862+
const withdrawParams = {
10863+
amount: '1000',
10864+
destination: USER_ADDRESS as Hex,
10865+
assetId:
10866+
'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId,
10867+
};
10868+
10869+
const stubGetAccountState = (availableBalance: string) =>
10870+
Object.defineProperty(provider, 'getAccountState', {
10871+
value: jest.fn().mockResolvedValue({ availableBalance }),
10872+
writable: true,
10873+
});
10874+
10875+
it('defers migration without firing exchange writes when the wallet has no HL ledger history', async () => {
10876+
const exchangeClient = createMockExchangeClient();
10877+
mockClientService.getExchangeClient = jest
10878+
.fn()
10879+
.mockReturnValue(exchangeClient);
10880+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
10881+
createMockInfoClient({
10882+
// Empty ledger = wallet has never deposited / withdrawn / interacted.
10883+
userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([]),
10884+
userAbstraction: jest.fn().mockResolvedValue('default'),
10885+
}),
10886+
);
10887+
stubGetAccountState('5000');
10888+
10889+
await provider.withdraw(withdrawParams).catch(() => undefined);
10890+
10891+
// Critical: neither migration write is called for unfunded wallets.
10892+
expect(exchangeClient.agentSetAbstraction).not.toHaveBeenCalled();
10893+
expect(exchangeClient.userSetAbstraction).not.toHaveBeenCalled();
10894+
// And no `logger.error` → no Sentry event.
10895+
expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
10896+
10897+
const trackCalls = (
10898+
mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock
10899+
).mock.calls.filter((call) => call[0] === 'Perp Account Setup');
10900+
const notApplicable = trackCalls.find(
10901+
(call) => call[1]?.status === 'not_applicable',
10902+
);
10903+
expect(notApplicable?.[1]).toEqual(
10904+
expect.objectContaining({
10905+
status: 'not_applicable',
10906+
error_message: 'no_hl_account',
10907+
}),
10908+
);
10909+
});
10910+
10911+
it('classifier safety net: suppresses Sentry when agentSetAbstraction races and rejects with user-not-found', async () => {
10912+
// Funded probe — gate passes — but the exchange write still rejects
10913+
// (deposit not yet visible, address reuse across networks, ...).
10914+
const userNotFound = new Error(
10915+
`User or API Wallet ${USER_ADDRESS} does not exist.`,
10916+
);
10917+
const exchangeClient = createMockExchangeClient();
10918+
exchangeClient.agentSetAbstraction = jest
10919+
.fn()
10920+
.mockRejectedValue(userNotFound);
10921+
mockClientService.getExchangeClient = jest
10922+
.fn()
10923+
.mockReturnValue(exchangeClient);
10924+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
10925+
createMockInfoClient({
10926+
// Funded clearinghouseState → gate passes
10927+
userAbstraction: jest.fn().mockResolvedValue('default'),
10928+
}),
10929+
);
10930+
stubGetAccountState('5000');
10931+
10932+
await provider.withdraw(withdrawParams).catch(() => undefined);
10933+
10934+
expect(exchangeClient.agentSetAbstraction).toHaveBeenCalled();
10935+
expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
10936+
10937+
const trackCalls = (
10938+
mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock
10939+
).mock.calls.filter((call) => call[0] === 'Perp Account Setup');
10940+
expect(
10941+
trackCalls.find((call) => call[1]?.status === 'not_applicable'),
10942+
).toBeDefined();
10943+
expect(
10944+
trackCalls.find((call) => call[1]?.status === 'failed'),
10945+
).toBeUndefined();
10946+
});
10947+
10948+
it('regression: unrelated exchange write failures still reach logger.error', async () => {
10949+
const exchangeClient = createMockExchangeClient();
10950+
exchangeClient.agentSetAbstraction = jest
10951+
.fn()
10952+
.mockRejectedValue(new Error('Insufficient margin'));
10953+
mockClientService.getExchangeClient = jest
10954+
.fn()
10955+
.mockReturnValue(exchangeClient);
10956+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
10957+
createMockInfoClient({
10958+
userAbstraction: jest.fn().mockResolvedValue('default'),
10959+
}),
10960+
);
10961+
stubGetAccountState('5000');
10962+
10963+
await provider.withdraw(withdrawParams).catch(() => undefined);
10964+
10965+
expect(mockPlatformDependencies.logger.error).toHaveBeenCalled();
10966+
10967+
const trackCalls = (
10968+
mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock
10969+
).mock.calls.filter((call) => call[0] === 'Perp Account Setup');
10970+
expect(
10971+
trackCalls.find((call) => call[1]?.status === 'failed'),
10972+
).toBeDefined();
10973+
});
10974+
10975+
it('funded wallets are unaffected: existing migration path runs as before', async () => {
10976+
const exchangeClient = createMockExchangeClient();
10977+
mockClientService.getExchangeClient = jest
10978+
.fn()
10979+
.mockReturnValue(exchangeClient);
10980+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
10981+
createMockInfoClient({
10982+
// Default funded clearinghouseState from factory
10983+
userAbstraction: jest.fn().mockResolvedValue('default'),
10984+
}),
10985+
);
10986+
stubGetAccountState('5000');
10987+
10988+
await provider.withdraw(withdrawParams).catch(() => undefined);
10989+
10990+
expect(exchangeClient.agentSetAbstraction).toHaveBeenCalledWith({
10991+
// SDK wire format: 'u' = unifiedAccount (see HL_ABSTRACTION_WIRE).
10992+
abstraction: 'u',
10993+
});
10994+
expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled();
10995+
});
10996+
10997+
it('walletRegistered cache hit skips the probe entirely', async () => {
10998+
// Arrange: pre-warm the walletRegistered cache so #isWalletOnHyperliquid
10999+
// returns true without calling userNonFundingLedgerUpdates.
11000+
const mockedCache = TradingReadinessCache as jest.Mocked<
11001+
typeof TradingReadinessCache
11002+
>;
11003+
mockedCache.getWalletRegistered.mockReturnValue({
11004+
known: true,
11005+
registered: true,
11006+
});
11007+
11008+
const exchangeClient = createMockExchangeClient();
11009+
mockClientService.getExchangeClient = jest
11010+
.fn()
11011+
.mockReturnValue(exchangeClient);
11012+
const infoClient = createMockInfoClient({
11013+
userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'),
11014+
});
11015+
mockClientService.getInfoClient = jest.fn().mockReturnValue(infoClient);
11016+
stubGetAccountState('5000');
11017+
11018+
await provider.withdraw(withdrawParams).catch(() => undefined);
11019+
11020+
// The ledger probe should never fire because the cache was hit.
11021+
expect(infoClient.userNonFundingLedgerUpdates).not.toHaveBeenCalled();
11022+
});
11023+
11024+
it('probe failure fails open — allows migration to proceed', async () => {
11025+
// Arrange: probe throws (transient network), should fail open (return true)
11026+
// and allow the migration to proceed.
11027+
const exchangeClient = createMockExchangeClient();
11028+
mockClientService.getExchangeClient = jest
11029+
.fn()
11030+
.mockReturnValue(exchangeClient);
11031+
mockClientService.getInfoClient = jest.fn().mockReturnValue(
11032+
createMockInfoClient({
11033+
userAbstraction: jest.fn().mockResolvedValue('default'),
11034+
userNonFundingLedgerUpdates: jest
11035+
.fn()
11036+
.mockRejectedValue(new Error('Network timeout')),
11037+
}),
11038+
);
11039+
stubGetAccountState('5000');
11040+
11041+
await provider.withdraw(withdrawParams).catch(() => undefined);
11042+
11043+
// Probe failed open → migration ran → agentSetAbstraction was called.
11044+
expect(exchangeClient.agentSetAbstraction).toHaveBeenCalled();
11045+
});
11046+
});
1084111047
});

0 commit comments

Comments
 (0)