Skip to content

Commit e090991

Browse files
authored
fix: handle snaps error (#6104)
## Explanation The `MultichainAssetsRatesController` was experiencing crashes when Snap requests failed due to parameter validation errors. the error handling was insufficient - when Snap requests failed, the controller will send a sentry error rather than gracefully handling the error and continuing operation. <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Changelog <!-- THIS SECTION IS NO LONGER NEEDED. The process for updating changelogs has changed. Please consult the "Updating changelogs" section of the Contributing doc for more. --> ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent b10198c commit e090991

File tree

3 files changed

+221
-26
lines changed

3 files changed

+221
-26
lines changed

packages/assets-controllers/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Bump `@metamask/keyring-api` from `^18.0.0` to `^19.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146))
1313

14+
### Fixed
15+
16+
- Improve error handling in `MultichainAssetsRatesController` for Snap request failures ([#6104](https://github.com/MetaMask/core/pull/6104))
17+
- Enhanced `#handleSnapRequest` method with detailed error logging and graceful failure recovery
18+
- Added null safety checks to prevent crashes when Snap requests return null
19+
- Controller now continues operation when individual Snap requests fail instead of crashing
20+
- Added comprehensive unit tests covering various error scenarios including JSON-RPC errors and network failures
21+
1422
## [72.0.0]
1523

1624
### Changed

packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,166 @@ describe('MultichainAssetsRatesController', () => {
526526
expect(updateSpy).toHaveBeenCalled();
527527
});
528528

529+
describe('error handling in snap requests', () => {
530+
it('handles JSON-RPC parameter validation errors gracefully', async () => {
531+
const { controller, messenger } = setupController();
532+
533+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
534+
const paramValidationError = new Error(
535+
'Invalid request params: At path: conversions.0.from -- Expected a value of type `CaipAssetType`, but received: `"swift:0/test-asset"`.',
536+
);
537+
538+
const snapHandler = jest.fn().mockRejectedValue(paramValidationError);
539+
messenger.registerActionHandler(
540+
'SnapController:handleRequest',
541+
snapHandler,
542+
);
543+
544+
await controller.updateAssetsRates();
545+
546+
// Should have logged the error with detailed context
547+
expect(consoleErrorSpy).toHaveBeenCalledWith(
548+
'Snap request failed for onAssetsConversion:',
549+
expect.objectContaining({
550+
snapId: 'test-snap',
551+
handler: 'onAssetsConversion',
552+
message: expect.stringContaining('Invalid request params'),
553+
params: expect.objectContaining({
554+
conversions: expect.arrayContaining([
555+
expect.objectContaining({
556+
from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
557+
to: 'swift:0/iso4217:USD',
558+
}),
559+
]),
560+
}),
561+
}),
562+
);
563+
564+
// Should not update state when snap request fails
565+
expect(controller.state.conversionRates).toStrictEqual({});
566+
567+
consoleErrorSpy.mockRestore();
568+
});
569+
570+
it('handles generic snap request errors gracefully', async () => {
571+
const { controller, messenger } = setupController();
572+
573+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
574+
const genericError = new Error('Network timeout');
575+
576+
const snapHandler = jest.fn().mockRejectedValue(genericError);
577+
messenger.registerActionHandler(
578+
'SnapController:handleRequest',
579+
snapHandler,
580+
);
581+
582+
await controller.updateAssetsRates();
583+
584+
// Should have logged the error with detailed context
585+
expect(consoleErrorSpy).toHaveBeenCalledWith(
586+
'Snap request failed for onAssetsConversion:',
587+
expect.objectContaining({
588+
snapId: 'test-snap',
589+
handler: 'onAssetsConversion',
590+
message: 'Network timeout',
591+
params: expect.any(Object),
592+
}),
593+
);
594+
595+
// Should not update state when snap request fails
596+
expect(controller.state.conversionRates).toStrictEqual({});
597+
598+
consoleErrorSpy.mockRestore();
599+
});
600+
601+
it('handles mixed success and failure scenarios', async () => {
602+
const { controller, messenger } = setupController({
603+
accountsAssets: [fakeNonEvmAccount, fakeEvmAccount2],
604+
});
605+
606+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
607+
608+
// Mock different responses for different calls
609+
const snapHandler = jest
610+
.fn()
611+
.mockResolvedValueOnce(fakeAccountRates) // First call succeeds (onAssetsConversion)
612+
.mockResolvedValueOnce({
613+
marketData: {
614+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': {
615+
'swift:0/iso4217:USD': fakeMarketData,
616+
},
617+
},
618+
}) // Second call succeeds (onAssetsMarketData)
619+
.mockRejectedValueOnce(new Error('Snap request failed')) // Third call fails (onAssetsConversion)
620+
.mockResolvedValueOnce(null); // Fourth call returns null (onAssetsMarketData)
621+
622+
messenger.registerActionHandler(
623+
'SnapController:handleRequest',
624+
snapHandler,
625+
);
626+
627+
await controller.updateAssetsRates();
628+
629+
// Should have logged the error for the failed request
630+
expect(consoleErrorSpy).toHaveBeenCalledWith(
631+
'Snap request failed for onAssetsConversion:',
632+
expect.objectContaining({
633+
message: 'Snap request failed',
634+
}),
635+
);
636+
637+
// Should still update state for the successful request
638+
expect(controller.state.conversionRates).toMatchObject({
639+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': {
640+
rate: '202.11',
641+
conversionTime: 1738539923277,
642+
currency: 'swift:0/iso4217:USD',
643+
marketData: fakeMarketData,
644+
},
645+
});
646+
647+
consoleErrorSpy.mockRestore();
648+
});
649+
650+
it('handles market data request errors independently', async () => {
651+
const { controller, messenger } = setupController();
652+
653+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
654+
655+
// Mock onAssetsConversion to succeed but onAssetsMarketData to fail
656+
const snapHandler = jest
657+
.fn()
658+
.mockResolvedValueOnce(fakeAccountRates) // onAssetsConversion succeeds
659+
.mockRejectedValueOnce(new Error('Market data unavailable')); // onAssetsMarketData fails
660+
661+
messenger.registerActionHandler(
662+
'SnapController:handleRequest',
663+
snapHandler,
664+
);
665+
666+
await controller.updateAssetsRates();
667+
668+
// Should have logged the market data error
669+
expect(consoleErrorSpy).toHaveBeenCalledWith(
670+
'Snap request failed for onAssetsMarketData:',
671+
expect.objectContaining({
672+
message: 'Market data unavailable',
673+
}),
674+
);
675+
676+
// Should still update state with conversion rates (without market data)
677+
expect(controller.state.conversionRates).toMatchObject({
678+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': {
679+
rate: '202.11',
680+
conversionTime: 1738539923277,
681+
currency: 'swift:0/iso4217:USD',
682+
},
683+
});
684+
685+
consoleErrorSpy.mockRestore();
686+
});
687+
});
688+
529689
describe('fetchHistoricalPricesForAsset', () => {
530690
it('throws an error if call to snap fails', async () => {
531691
const testAsset = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501';

packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,12 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro
352352
snapId: account?.metadata.snap?.id as SnapId,
353353
handler: HandlerType.OnAssetsConversion,
354354
params: conversions,
355-
})) as OnAssetsConversionResponse;
355+
})) as OnAssetsConversionResponse | null;
356+
357+
// If the snap request failed, return empty rates
358+
if (!accountRatesResponse) {
359+
return {};
360+
}
356361

357362
// Prepare assets param for onAssetsMarketData
358363
const currentCurrencyCaip =
@@ -366,7 +371,7 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro
366371
snapId: account?.metadata.snap?.id as SnapId,
367372
handler: HandlerType.OnAssetsMarketData,
368373
params: assetsParam as OnAssetsMarketDataArguments,
369-
})) as OnAssetsMarketDataResponse;
374+
})) as OnAssetsMarketDataResponse | null;
370375

371376
// Merge market data into conversion rates if available
372377
const mergedRates = this.#mergeMarketDataIntoConversionRates(
@@ -417,14 +422,22 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro
417422
'AccountsController:getSelectedMultichainAccount',
418423
);
419424
try {
420-
const historicalPricesResponse = await this.#handleSnapRequest({
421-
snapId: selectedAccount?.metadata.snap?.id as SnapId,
422-
handler: HandlerType.OnAssetHistoricalPrice,
423-
params: {
424-
from: asset,
425-
to: currentCaipCurrency,
425+
const historicalPricesResponse = await this.messagingSystem.call(
426+
'SnapController:handleRequest',
427+
{
428+
snapId: selectedAccount?.metadata.snap?.id as SnapId,
429+
origin: 'metamask',
430+
handler: HandlerType.OnAssetHistoricalPrice,
431+
request: {
432+
jsonrpc: '2.0',
433+
method: HandlerType.OnAssetHistoricalPrice,
434+
params: {
435+
from: asset,
436+
to: currentCaipCurrency,
437+
},
438+
},
426439
},
427-
});
440+
);
428441

429442
// skip state update if no historical prices are returned
430443
if (!historicalPricesResponse) {
@@ -544,8 +557,12 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro
544557
* @returns A flattened rates object.
545558
*/
546559
#flattenRates(
547-
assetsConversionResponse: ConversionRatesWithMarketData,
560+
assetsConversionResponse: ConversionRatesWithMarketData | null,
548561
): Record<CaipAssetType, UnifiedAssetConversion | null> {
562+
if (!assetsConversionResponse?.conversionRates) {
563+
return {};
564+
}
565+
549566
const { conversionRates } = assetsConversionResponse;
550567

551568
return Object.fromEntries(
@@ -633,28 +650,38 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro
633650
| OnAssetsConversionResponse
634651
| OnAssetHistoricalPriceResponse
635652
| OnAssetsMarketDataResponse
636-
| null
653+
| undefined
637654
> {
638-
return this.messagingSystem.call('SnapController:handleRequest', {
639-
snapId,
640-
origin: 'metamask',
641-
handler,
642-
request: {
643-
jsonrpc: '2.0',
644-
method: handler,
655+
try {
656+
return (await this.messagingSystem.call('SnapController:handleRequest', {
657+
snapId,
658+
origin: 'metamask',
659+
handler,
660+
request: {
661+
jsonrpc: '2.0',
662+
method: handler,
663+
params,
664+
},
665+
})) as
666+
| OnAssetsConversionResponse
667+
| OnAssetHistoricalPriceResponse
668+
| OnAssetsMarketDataResponse
669+
| undefined;
670+
} catch (error) {
671+
console.error(`Snap request failed for ${handler}:`, {
672+
snapId,
673+
handler,
674+
message: (error as Error).message,
645675
params,
646-
},
647-
}) as Promise<
648-
| OnAssetsConversionResponse
649-
| OnAssetHistoricalPriceResponse
650-
| OnAssetsMarketDataResponse
651-
| null
652-
>;
676+
});
677+
// Ignore
678+
return undefined;
679+
}
653680
}
654681

655682
#mergeMarketDataIntoConversionRates(
656683
accountRatesResponse: OnAssetsConversionResponse,
657-
marketDataResponse: OnAssetsMarketDataResponse,
684+
marketDataResponse: OnAssetsMarketDataResponse | null,
658685
): ConversionRatesWithMarketData {
659686
// Early return if no market data to merge
660687
if (!marketDataResponse?.marketData) {

0 commit comments

Comments
 (0)