Skip to content

Commit c669ea6

Browse files
authored
feat: calculate bridge quote metadata in @metamask/bridge-controller (#5614)
1 parent 14c8ec5 commit c669ea6

21 files changed

+2457
-41
lines changed

packages/bridge-controller/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- **BREAKING:** Add `@metamask/assets-controllers` as a required peer dependency at `^56.0.0` ([#5614](https://github.com/MetaMask/core/pull/5614))
13+
- Add `reselect` as a dependency at `^5.1.1` ([#5614](https://github.com/MetaMask/core/pull/5614))
14+
- **BREAKING:** assetExchangeRates added to BridgeController state to support tokens which are not supported by assets controllers ([#5614](https://github.com/MetaMask/core/pull/5614))
15+
- selectExchangeRateByChainIdAndAddress selector added, which looks up exchange rates from assets and bridge controller states ([#5614](https://github.com/MetaMask/core/pull/5614))
16+
- selectBridgeQuotes selector added, which returns sorted quotes including their metadata ([#5614](https://github.com/MetaMask/core/pull/5614))
17+
- selectIsQuoteExpired selector added, which returns whether quotes are expired or stale ([#5614](https://github.com/MetaMask/core/pull/5614))
18+
1019
### Changed
1120

21+
- **BREAKING:** Change TokenAmountValues key types from BigNumber to string ([#5614](https://github.com/MetaMask/core/pull/5614))
22+
- **BREAKING:** Assets controller getState actions have been added to `AllowedActions` so clients will need to include `TokenRatesController:getState`,`MultichainAssetsRatesController:getState` and `CurrencyRateController:getState` in controller initializations ([#5614](https://github.com/MetaMask/core/pull/5614))
23+
- Make srcAsset and destAsset optional in Step type to be optional ([#5614](https://github.com/MetaMask/core/pull/5614))
24+
- Make QuoteResponse trade generic to support Solana quotes which have string trade data ([#5614](https://github.com/MetaMask/core/pull/5614))
1225
- Bump `@metamask/multichain-network-controller` peer dependency to `^0.4.0` ([#5649](https://github.com/MetaMask/core/pull/5649))
1326

1427
## [13.0.0]

packages/bridge-controller/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,18 @@
5454
"@ethersproject/providers": "^5.7.0",
5555
"@metamask/base-controller": "^8.0.0",
5656
"@metamask/controller-utils": "^11.7.0",
57+
"@metamask/gas-fee-controller": "^23.0.0",
5758
"@metamask/keyring-api": "^17.4.0",
5859
"@metamask/metamask-eth-abis": "^3.1.1",
5960
"@metamask/multichain-network-controller": "^0.4.0",
6061
"@metamask/polling-controller": "^13.0.0",
61-
"@metamask/snaps-utils": "^8.10.0",
62-
"@metamask/utils": "^11.2.0"
62+
"@metamask/utils": "^11.2.0",
63+
"bignumber.js": "^9.1.2",
64+
"reselect": "^5.1.1"
6365
},
6466
"devDependencies": {
6567
"@metamask/accounts-controller": "^27.0.0",
68+
"@metamask/assets-controllers": "^56.0.0",
6669
"@metamask/auto-changelog": "^3.4.4",
6770
"@metamask/eth-json-rpc-provider": "^4.1.8",
6871
"@metamask/network-controller": "^23.2.0",
@@ -82,6 +85,7 @@
8285
},
8386
"peerDependencies": {
8487
"@metamask/accounts-controller": "^27.0.0",
88+
"@metamask/assets-controllers": "^56.0.0",
8589
"@metamask/network-controller": "^23.0.0",
8690
"@metamask/snaps-controllers": "^9.19.0",
8791
"@metamask/transaction-controller": "^54.0.0"

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Contract } from '@ethersproject/contracts';
22
import { SolScope } from '@metamask/keyring-api';
3-
import { HandlerType } from '@metamask/snaps-utils';
43
import type { Hex } from '@metamask/utils';
54
import { bigIntToHex } from '@metamask/utils';
65
import nock from 'nock';
@@ -12,6 +11,7 @@ import {
1211
DEFAULT_BRIDGE_CONTROLLER_STATE,
1312
} from './constants/bridge';
1413
import { SWAPS_API_V2_BASE_URL } from './constants/swaps';
14+
import * as selectors from './selectors';
1515
import {
1616
ChainId,
1717
type BridgeControllerMessenger,
@@ -44,6 +44,7 @@ jest.mock('@ethersproject/contracts', () => {
4444

4545
const getLayer1GasFeeMock = jest.fn();
4646
const mockFetchFn = handleFetch;
47+
let fetchAssetPricesSpy: jest.SpyInstance;
4748

4849
describe('BridgeController', function () {
4950
let bridgeController: BridgeController;
@@ -154,6 +155,14 @@ describe('BridgeController', function () {
154155
symbol: 'ABC',
155156
},
156157
]);
158+
159+
fetchAssetPricesSpy = jest
160+
.spyOn(fetchUtils, 'fetchAssetPrices')
161+
.mockResolvedValue({
162+
'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': {
163+
usd: '100',
164+
},
165+
});
157166
bridgeController.resetState();
158167
});
159168

@@ -273,6 +282,9 @@ describe('BridgeController', function () {
273282
address: '0x123',
274283
provider: jest.fn(),
275284
selectedNetworkClientId: 'selectedNetworkClientId',
285+
currencyRates: {},
286+
marketData: {},
287+
conversionRates: {},
276288
} as never);
277289

278290
const fetchBridgeQuotesSpy = jest
@@ -328,6 +340,7 @@ describe('BridgeController', function () {
328340
insufficientBal: false,
329341
},
330342
});
343+
expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1);
331344

332345
expect(bridgeController.state).toStrictEqual(
333346
expect.objectContaining({
@@ -429,7 +442,14 @@ describe('BridgeController', function () {
429442
address: '0x123',
430443
provider: jest.fn(),
431444
selectedNetworkClientId: 'selectedNetworkClientId',
445+
currentCurrency: 'usd',
446+
currencyRates: {},
447+
marketData: {},
448+
conversionRates: {},
432449
} as never);
450+
jest
451+
.spyOn(selectors, 'selectIsAssetExchangeRateInState')
452+
.mockReturnValue(true);
433453

434454
const fetchBridgeQuotesSpy = jest
435455
.spyOn(fetchUtils, 'fetchBridgeQuotes')
@@ -454,7 +474,7 @@ describe('BridgeController', function () {
454474

455475
const quoteParams = {
456476
srcChainId: '0x1',
457-
destChainId: '0x10',
477+
destChainId: '0xa',
458478
srcTokenAddress: '0x0000000000000000000000000000000000000000',
459479
destTokenAddress: '0x123',
460480
srcTokenAmount: '1000000000000000000',
@@ -476,6 +496,7 @@ describe('BridgeController', function () {
476496
insufficientBal: true,
477497
},
478498
});
499+
expect(fetchAssetPricesSpy).not.toHaveBeenCalled();
479500

480501
expect(bridgeController.state).toStrictEqual(
481502
expect.objectContaining({
@@ -611,7 +632,7 @@ describe('BridgeController', function () {
611632

612633
const quoteParams = {
613634
srcChainId: '0x1',
614-
destChainId: '0x10',
635+
destChainId: '0xa',
615636
srcTokenAddress: '0x0000000000000000000000000000000000000000',
616637
destTokenAddress: '0x123',
617638
srcTokenAmount: '1000000000000000000',
@@ -793,7 +814,7 @@ describe('BridgeController', function () {
793814
});
794815

795816
const quoteParams = {
796-
srcChainId: '0x10',
817+
srcChainId: '0xa',
797818
destChainId: '0x1',
798819
srcTokenAddress: '0x4200000000000000000000000000000000000006',
799820
destTokenAddress: '0x0000000000000000000000000000000000000000',
@@ -895,7 +916,7 @@ describe('BridgeController', function () {
895916
});
896917

897918
const quoteParams = {
898-
srcChainId: '0x10',
919+
srcChainId: '0xa',
899920
destChainId: '0x1',
900921
srcTokenAddress: '0x4200000000000000000000000000000000000006',
901922
destTokenAddress: '0x0000000000000000000000000000000000000000',
@@ -933,7 +954,7 @@ describe('BridgeController', function () {
933954
{
934955
snapId: 'npm:@metamask/solana-snap',
935956
origin: 'metamask',
936-
handler: HandlerType.OnRpcRequest,
957+
handler: 'onRpcRequest',
937958
request: {
938959
method: 'getFeeForTransaction',
939960
params: {

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

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@ import type { ChainId } from '@metamask/controller-utils';
66
import { abiERC20 } from '@metamask/metamask-eth-abis';
77
import type { NetworkClientId } from '@metamask/network-controller';
88
import { StaticIntervalPollingController } from '@metamask/polling-controller';
9-
import { type SnapId } from '@metamask/snaps-sdk';
10-
import { HandlerType } from '@metamask/snaps-utils';
119
import type { TransactionParams } from '@metamask/transaction-controller';
12-
import { numberToHex } from '@metamask/utils';
13-
import type { Hex } from '@metamask/utils';
10+
import type { CaipAssetType } from '@metamask/utils';
11+
import { numberToHex, type Hex } from '@metamask/utils';
1412

1513
import {
1614
type BridgeClientId,
@@ -21,9 +19,11 @@ import {
2119
REFRESH_INTERVAL_MS,
2220
} from './constants/bridge';
2321
import { CHAIN_IDS } from './constants/chains';
24-
import type { GenericQuoteRequest, SolanaFees } from './types';
22+
import { selectIsAssetExchangeRateInState } from './selectors';
2523
import {
2624
type L1GasFees,
25+
type GenericQuoteRequest,
26+
type SolanaFees,
2727
type QuoteResponse,
2828
type TxData,
2929
type BridgeControllerState,
@@ -32,6 +32,7 @@ import {
3232
BridgeFeatureFlagsKey,
3333
RequestStatus,
3434
} from './types';
35+
import { getAssetIdsForToken, toExchangeRates } from './utils/assets';
3536
import { hasSufficientBalance } from './utils/balance';
3637
import {
3738
getDefaultBridgeControllerState,
@@ -43,7 +44,11 @@ import {
4344
formatChainIdToCaip,
4445
formatChainIdToHex,
4546
} from './utils/caip-formatters';
46-
import { fetchBridgeFeatureFlags, fetchBridgeQuotes } from './utils/fetch';
47+
import {
48+
fetchAssetPrices,
49+
fetchBridgeFeatureFlags,
50+
fetchBridgeQuotes,
51+
} from './utils/fetch';
4752
import { isValidQuoteRequest } from './utils/quote';
4853

4954
const metadata: StateMetadata<BridgeControllerState> = {
@@ -79,6 +84,10 @@ const metadata: StateMetadata<BridgeControllerState> = {
7984
persist: false,
8085
anonymous: false,
8186
},
87+
assetExchangeRates: {
88+
persist: false,
89+
anonymous: false,
90+
},
8291
};
8392

8493
const RESET_STATE_ABORT_MESSAGE = 'Reset controller state';
@@ -197,6 +206,10 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
197206
DEFAULT_BRIDGE_CONTROLLER_STATE.quotesInitialLoadTime;
198207
});
199208

209+
await this.#fetchAssetExchangeRates(updatedQuoteRequest).catch((error) =>
210+
console.warn('Failed to fetch asset exchange rates', error),
211+
);
212+
200213
if (isValidQuoteRequest(updatedQuoteRequest)) {
201214
this.#quotesFirstFetched = Date.now();
202215
const providerConfig = this.#getSelectedNetworkClient()?.configuration;
@@ -229,6 +242,81 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
229242
}
230243
};
231244

245+
/**
246+
* Fetches the exchange rates for the assets in the quote request if they are not already in the state
247+
* In addition to the selected tokens, this also fetches the native asset for the source and destination chains
248+
*
249+
* @param quoteRequest - The quote request
250+
* @param quoteRequest.srcChainId - The source chain ID
251+
* @param quoteRequest.srcTokenAddress - The source token address
252+
* @param quoteRequest.destChainId - The destination chain ID
253+
* @param quoteRequest.destTokenAddress - The destination token address
254+
*/
255+
readonly #fetchAssetExchangeRates = async ({
256+
srcChainId,
257+
srcTokenAddress,
258+
destChainId,
259+
destTokenAddress,
260+
}: Partial<GenericQuoteRequest>) => {
261+
const assetIds: Set<CaipAssetType> = new Set([]);
262+
263+
const exchangeRateSources = {
264+
...this.messagingSystem.call('MultichainAssetsRatesController:getState'),
265+
...this.messagingSystem.call('CurrencyRateController:getState'),
266+
...this.messagingSystem.call('TokenRatesController:getState'),
267+
...this.state,
268+
};
269+
270+
if (
271+
srcTokenAddress &&
272+
srcChainId &&
273+
!selectIsAssetExchangeRateInState(
274+
exchangeRateSources,
275+
srcChainId,
276+
srcTokenAddress,
277+
)
278+
) {
279+
getAssetIdsForToken(srcTokenAddress, srcChainId).forEach((assetId) =>
280+
assetIds.add(assetId),
281+
);
282+
}
283+
if (
284+
destTokenAddress &&
285+
destChainId &&
286+
!selectIsAssetExchangeRateInState(
287+
exchangeRateSources,
288+
destChainId,
289+
destTokenAddress,
290+
)
291+
) {
292+
getAssetIdsForToken(destTokenAddress, destChainId).forEach((assetId) =>
293+
assetIds.add(assetId),
294+
);
295+
}
296+
297+
const currency = this.messagingSystem.call(
298+
'CurrencyRateController:getState',
299+
).currentCurrency;
300+
301+
if (assetIds.size === 0) {
302+
return;
303+
}
304+
305+
const pricesByAssetId = await fetchAssetPrices({
306+
assetIds,
307+
currencies: new Set([currency]),
308+
clientId: this.#clientId,
309+
fetchFn: this.#fetchFn,
310+
});
311+
const exchangeRates = toExchangeRates(currency, pricesByAssetId);
312+
this.update((state) => {
313+
state.assetExchangeRates = {
314+
...state.assetExchangeRates,
315+
...exchangeRates,
316+
};
317+
});
318+
};
319+
232320
readonly #hasSufficientBalance = async (
233321
quoteRequest: GenericQuoteRequest,
234322
) => {
@@ -272,6 +360,8 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
272360
state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError;
273361
state.quotesRefreshCount =
274362
DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount;
363+
state.assetExchangeRates =
364+
DEFAULT_BRIDGE_CONTROLLER_STATE.assetExchangeRates;
275365

276366
// Keep feature flags
277367
const originalFeatureFlags = state.bridgeFeatureFlags;
@@ -449,9 +539,10 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
449539
const { value: fees } = (await this.messagingSystem.call(
450540
'SnapController:handleRequest',
451541
{
452-
snapId: selectedAccount.metadata.snap.id as SnapId,
542+
// TODO fix these types
543+
snapId: selectedAccount.metadata.snap.id as never,
453544
origin: 'metamask',
454-
handler: HandlerType.OnRpcRequest,
545+
handler: 'onRpcRequest' as never,
455546
request: {
456547
method: 'getFeeForTransaction',
457548
params: {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = {
6565
quotesLoadingStatus: null,
6666
quoteFetchError: null,
6767
quotesRefreshCount: 0,
68+
assetExchangeRates: {},
6869
};
6970

7071
export const METABRIDGE_CHAIN_TO_ADDRESS_MAP: Record<Hex, string> = {

packages/bridge-controller/src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export {
8080
getDefaultBridgeControllerState,
8181
} from './utils/bridge';
8282

83-
export { isValidQuoteRequest } from './utils/quote';
83+
export { isValidQuoteRequest, formatEtaInMinutes } from './utils/quote';
8484

8585
export { calcLatestSrcBalance } from './utils/balance';
8686

@@ -91,3 +91,17 @@ export {
9191
formatChainIdToHex,
9292
formatAddressToCaipReference,
9393
} from './utils/caip-formatters';
94+
95+
export {
96+
selectBridgeQuotes,
97+
type BridgeAppState,
98+
selectExchangeRateByChainIdAndAddress,
99+
/**
100+
* Returns whether a quote is expired
101+
*
102+
* @param state The state of the bridge controller and its dependency controllers
103+
* @param currentTimeInMs The current timestamp in milliseconds (e.g. `Date.now()`)
104+
* @returns Whether the quote is expired
105+
*/
106+
selectIsQuoteExpired,
107+
} from './selectors';

0 commit comments

Comments
 (0)