Skip to content

Commit a441b82

Browse files
authored
fix: fail gracefully when L1 gas fees are not available (#5910)
## Explanation This fixes L1 and Solana fee calculations in the BridgeController such that some quotes can be returned if exceptions are thrown by dependencies (snap and RPCs). <!-- 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 Fixes https://consensyssoftware.atlassian.net/browse/MMS-2568 <!-- 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 - [x] 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 - [x] 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 - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent 884962a commit a441b82

File tree

3 files changed

+254
-65
lines changed

3 files changed

+254
-65
lines changed

packages/bridge-controller/CHANGELOG.md

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

1212
- Remove `error_message` property from QuotesRequested event payload ([#5900](https://github.com/MetaMask/core/pull/5900))
13+
- Fail gracefully when fee calculations return invalid value or throw errors
14+
- Filter out single quote if `TransactionController.getLayer1GasFee` returns `undefined` ([#5910](https://github.com/MetaMask/core/pull/5910))
15+
- Filter out single quote if an error is thrown by `getLayer1GasFee` ([#5910](https://github.com/MetaMask/core/pull/5910))
16+
- Filter out single quote if an error is thrown by Solana snap's `getFeeForTransaction` method ([#5910](https://github.com/MetaMask/core/pull/5910))
1317

1418
## [32.0.0]
1519

packages/bridge-controller/src/bridge-controller.test.ts

Lines changed: 185 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
/* eslint-disable jest/no-conditional-in-test */
12
import { Contract } from '@ethersproject/contracts';
2-
import { SolScope } from '@metamask/keyring-api';
3-
import type { Hex } from '@metamask/utils';
4-
import { bigIntToHex } from '@metamask/utils';
3+
import {
4+
EthAccountType,
5+
EthScope,
6+
SolAccountType,
7+
SolScope,
8+
} from '@metamask/keyring-api';
59
import nock from 'nock';
610

711
import { BridgeController } from './bridge-controller';
@@ -201,6 +205,10 @@ describe('BridgeController', function () {
201205
};
202206

203207
it('updateBridgeQuoteRequestParams should update the quoteRequest state', async function () {
208+
messengerMock.call.mockReturnValue({
209+
currentCurrency: 'usd',
210+
} as never);
211+
204212
await bridgeController.updateBridgeQuoteRequestParams(
205213
{ srcChainId: 1 },
206214
metricsContext,
@@ -659,23 +667,31 @@ describe('BridgeController', function () {
659667
): ReturnType<BridgeControllerMessenger['call']> => {
660668
const actionType = args[0];
661669

662-
// eslint-disable-next-line jest/no-conditional-in-test
663670
if (actionType === 'AccountsController:getSelectedMultichainAccount') {
664671
return {
672+
type: SolAccountType.DataAccount,
673+
id: 'account1',
674+
scopes: [SolScope.Mainnet],
675+
methods: [],
665676
address: '0x123',
666677
metadata: {
667678
snap: {
668679
id: 'npm:@metamask/solana-snap',
669680
name: 'Solana Snap',
670681
enabled: true,
671682
},
672-
} as never,
683+
name: 'Account 1',
684+
importTime: 1717334400,
685+
keyring: {
686+
type: 'Keyring',
687+
},
688+
},
673689
options: {
674690
scope: 'mainnet',
675691
},
676-
} as never;
692+
};
677693
}
678-
// eslint-disable-next-line jest/no-conditional-in-test
694+
679695
if (actionType === 'NetworkController:getNetworkClientById') {
680696
return {
681697
configuration: { rpcUrl: 'https://rpc.tenderly.co' },
@@ -820,11 +836,9 @@ describe('BridgeController', function () {
820836
// Setup
821837
const mockMessenger = {
822838
call: jest.fn().mockImplementation((methodName) => {
823-
// eslint-disable-next-line jest/no-conditional-in-test
824839
if (methodName === 'NetworkController:getNetworkClientById') {
825840
return { provider: null };
826841
}
827-
// eslint-disable-next-line jest/no-conditional-in-test
828842
if (methodName === 'NetworkController:getState') {
829843
return { selectedNetworkClientId: 'testNetworkClientId' };
830844
}
@@ -854,29 +868,47 @@ describe('BridgeController', function () {
854868
[
855869
'should append l1GasFees if srcChain is 10 and srcToken is erc20',
856870
mockBridgeQuotesErc20Native as QuoteResponse[],
857-
bigIntToHex(BigInt('2608710388388') * 2n),
858-
12,
871+
['0x2', '0x1'],
872+
[6, 12],
859873
],
860874
[
861875
'should append l1GasFees if srcChain is 10 and srcToken is native',
862876
mockBridgeQuotesNativeErc20 as unknown as QuoteResponse[],
863-
bigIntToHex(BigInt('2608710388388')),
864-
2,
877+
['0x1', '0x1'],
878+
[2, 2],
865879
],
866880
[
867881
'should not append l1GasFees if srcChain is not 10',
868882
mockBridgeQuotesNativeErc20Eth as unknown as QuoteResponse[],
869-
undefined,
870-
0,
883+
[],
884+
[2, 0],
885+
],
886+
[
887+
'should filter out quote if getL1Fees returns undefined',
888+
mockBridgeQuotesErc20Native as unknown as QuoteResponse[],
889+
['0x2', undefined],
890+
[5, 12],
891+
],
892+
[
893+
'should filter out quote if L1 fee calculation fails',
894+
mockBridgeQuotesErc20Native as unknown as QuoteResponse[],
895+
['0x2', '0x1', 'L1 gas fee calculation failed'],
896+
[5, 11],
871897
],
872898
])(
873899
'updateBridgeQuoteRequestParams: %s',
874900
async (
875901
_testTitle: string,
876902
quoteResponse: QuoteResponse[],
877-
l1GasFeesInHexWei: Hex | undefined,
878-
getLayer1GasFeeMockCallCount: number,
903+
[totalL1GasFeesInHexWei, tradeL1GasFeesInHexWei, tradeL1GasFeeError]: (
904+
| string
905+
| undefined
906+
)[],
907+
[expectedQuotesLength, expectedGetLayer1GasFeeMockCallCount]: number[],
879908
) => {
909+
const errorSpy = jest
910+
.spyOn(console, 'error')
911+
.mockImplementation(jest.fn());
880912
jest.useFakeTimers();
881913
const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling');
882914
const startPollingSpy = jest.spyOn(bridgeController, 'startPolling');
@@ -888,7 +920,27 @@ describe('BridgeController', function () {
888920
provider: jest.fn(),
889921
selectedNetworkClientId: 'selectedNetworkClientId',
890922
} as never);
891-
getLayer1GasFeeMock.mockResolvedValue('0x25F63418AA4');
923+
924+
for (const [index, quote] of quoteResponse.entries()) {
925+
if (tradeL1GasFeeError && index === 0) {
926+
getLayer1GasFeeMock.mockRejectedValueOnce(
927+
new Error(tradeL1GasFeeError),
928+
);
929+
continue;
930+
}
931+
932+
if (quote.approval) {
933+
getLayer1GasFeeMock.mockResolvedValueOnce('0x1');
934+
}
935+
936+
if (tradeL1GasFeesInHexWei === undefined && index === 0) {
937+
getLayer1GasFeeMock.mockResolvedValueOnce(undefined);
938+
continue;
939+
}
940+
getLayer1GasFeeMock.mockResolvedValueOnce(
941+
tradeL1GasFeesInHexWei ?? '0x1',
942+
);
943+
}
892944

893945
const fetchBridgeQuotesSpy = jest
894946
.spyOn(fetchUtils, 'fetchBridgeQuotes')
@@ -967,6 +1019,7 @@ describe('BridgeController', function () {
9671019
jest.advanceTimersByTime(1500);
9681020
await flushPromises();
9691021
const { quotes } = bridgeController.state;
1022+
expect(quotes).toHaveLength(expectedQuotesLength);
9701023
expect(bridgeController.state).toStrictEqual(
9711024
expect.objectContaining({
9721025
quoteRequest: { ...quoteRequest, insufficientBal: true },
@@ -975,7 +1028,10 @@ describe('BridgeController', function () {
9751028
}),
9761029
);
9771030
quotes.forEach((quote) => {
978-
const expectedQuote = { ...quote, l1GasFeesInHexWei };
1031+
const expectedQuote = {
1032+
...quote,
1033+
l1GasFeesInHexWei: totalL1GasFeesInHexWei,
1034+
};
9791035
// eslint-disable-next-line jest/prefer-strict-equal
9801036
expect(quote).toEqual(expectedQuote);
9811037
});
@@ -984,8 +1040,10 @@ describe('BridgeController', function () {
9841040
expect(firstFetchTime).toBeGreaterThan(0);
9851041

9861042
expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(
987-
getLayer1GasFeeMockCallCount,
1043+
expectedGetLayer1GasFeeMockCallCount,
9881044
);
1045+
1046+
expect(errorSpy).toHaveBeenCalledTimes(tradeL1GasFeeError ? 1 : 0);
9891047
},
9901048
);
9911049

@@ -1201,12 +1259,14 @@ describe('BridgeController', function () {
12011259
[
12021260
'should append solanaFees for Solana quotes',
12031261
mockBridgeQuotesSolErc20 as unknown as QuoteResponse[],
1262+
2,
12041263
'5000',
12051264
solanaSnapCalls,
12061265
],
12071266
[
12081267
'should not append solanaFees if selected account is not a snap',
12091268
mockBridgeQuotesSolErc20 as unknown as QuoteResponse[],
1269+
2,
12101270
undefined,
12111271
[],
12121272
false,
@@ -1217,6 +1277,7 @@ describe('BridgeController', function () {
12171277
...mockBridgeQuotesSolErc20,
12181278
...mockBridgeQuotesErc20Native,
12191279
] as unknown as QuoteResponse[],
1280+
8,
12201281
undefined,
12211282
mixedQuotesSnapCalls,
12221283
],
@@ -1225,6 +1286,7 @@ describe('BridgeController', function () {
12251286
async (
12261287
_testTitle: string,
12271288
quoteResponse: QuoteResponse[],
1289+
expectedQuotesLength: number,
12281290
expectedFees: string | undefined,
12291291
expectedSnapCalls: typeof solanaSnapCalls,
12301292
isSnapAccount = true,
@@ -1242,27 +1304,54 @@ describe('BridgeController', function () {
12421304
): ReturnType<BridgeControllerMessenger['call']> => {
12431305
const actionType = args[0];
12441306

1245-
// eslint-disable-next-line jest/no-conditional-in-test
12461307
if (
1247-
// eslint-disable-next-line jest/no-conditional-in-test
12481308
actionType === 'AccountsController:getSelectedMultichainAccount' &&
12491309
isSnapAccount
12501310
) {
12511311
return {
1312+
type: SolAccountType.DataAccount,
1313+
id: 'account1',
1314+
scopes: [SolScope.Mainnet],
1315+
methods: [],
12521316
address: '0x123',
12531317
metadata: {
1318+
name: 'Account 1',
1319+
importTime: 1717334400,
1320+
keyring: {
1321+
type: 'Keyring',
1322+
},
12541323
snap: {
12551324
id: 'npm:@metamask/solana-snap',
12561325
name: 'Solana Snap',
12571326
enabled: true,
12581327
},
1259-
} as never,
1328+
},
12601329
options: {
12611330
scope: 'mainnet',
12621331
},
1263-
} as never;
1332+
};
1333+
}
1334+
if (
1335+
actionType === 'AccountsController:getSelectedMultichainAccount'
1336+
) {
1337+
return {
1338+
type: EthAccountType.Eoa,
1339+
id: 'account1',
1340+
scopes: [EthScope.Eoa],
1341+
methods: [],
1342+
address: '0x123',
1343+
metadata: {
1344+
name: 'Account 1',
1345+
importTime: 1717334400,
1346+
keyring: {
1347+
type: 'Keyring',
1348+
},
1349+
},
1350+
options: {
1351+
scope: 'mainnet',
1352+
},
1353+
};
12641354
}
1265-
// eslint-disable-next-line jest/no-conditional-in-test
12661355
if (actionType === 'SnapController:handleRequest') {
12671356
return { value: '5000' } as never;
12681357
}
@@ -1330,6 +1419,8 @@ describe('BridgeController', function () {
13301419
);
13311420

13321421
expect(snapCalls).toMatchObject(expectedSnapCalls);
1422+
1423+
expect(quotes).toHaveLength(expectedQuotesLength);
13331424
},
13341425
);
13351426

@@ -1605,4 +1696,73 @@ describe('BridgeController', function () {
16051696
expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot();
16061697
});
16071698
});
1699+
1700+
describe('trackUnifiedSwapBridgeEvent client-side call exceptions', () => {
1701+
beforeEach(() => {
1702+
jest.clearAllMocks();
1703+
messengerMock.call.mockImplementation(
1704+
(
1705+
...args: Parameters<BridgeControllerMessenger['call']>
1706+
): ReturnType<BridgeControllerMessenger['call']> => {
1707+
const actionType = args[0];
1708+
if (
1709+
actionType === 'AccountsController:getSelectedMultichainAccount'
1710+
) {
1711+
return {
1712+
type: SolAccountType.DataAccount,
1713+
id: 'account1',
1714+
scopes: [SolScope.Mainnet],
1715+
methods: [],
1716+
address: '0x123',
1717+
metadata: {
1718+
snap: {
1719+
id: 'npm:@metamask/solana-snap',
1720+
name: 'Solana Snap',
1721+
enabled: true,
1722+
},
1723+
name: 'Account 1',
1724+
importTime: 1717334400,
1725+
} as never,
1726+
options: {
1727+
scope: 'mainnet',
1728+
},
1729+
};
1730+
}
1731+
return {
1732+
provider: jest.fn() as never,
1733+
selectedNetworkClientId: 'selectedNetworkClientId',
1734+
rpcUrl: 'https://mainnet.infura.io/v3/123',
1735+
configuration: {
1736+
chainId: 'eip155:1',
1737+
},
1738+
} as never;
1739+
},
1740+
);
1741+
});
1742+
1743+
it('should not track the event if the account keyring type is not set', () => {
1744+
const errorSpy = jest
1745+
.spyOn(console, 'error')
1746+
.mockImplementation(jest.fn());
1747+
bridgeController.trackUnifiedSwapBridgeEvent(
1748+
UnifiedSwapBridgeEventName.QuotesReceived,
1749+
{
1750+
warnings: ['warning1'],
1751+
usd_quoted_gas: 0,
1752+
gas_included: false,
1753+
quoted_time_minutes: 10,
1754+
usd_quoted_return: 100,
1755+
price_impact: 0,
1756+
provider: 'provider_bridge',
1757+
best_quote_provider: 'provider_bridge2',
1758+
},
1759+
);
1760+
expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0);
1761+
expect(errorSpy).toHaveBeenCalledTimes(1);
1762+
expect(errorSpy).toHaveBeenCalledWith(
1763+
'Error tracking cross-chain swaps MetaMetrics event',
1764+
new TypeError("Cannot read properties of undefined (reading 'type')"),
1765+
);
1766+
});
1767+
});
16081768
});

0 commit comments

Comments
 (0)