Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Memoize hot-path asset ID / legacy-format conversions to cut repeated keccak256 (`toChecksumAddress`) and CAIP parsing ([#9555](https://github.com/MetaMask/core/pull/9555))
- `normalizeAssetId` uses lodash `memoize` so already-normalized IDs skip re-checksumming across the pipeline
- `formatExchangeRatesForBridge` caches the last result on input identity (`===` for BaseController slices, lodash `isEqual` for rebuilt native maps); exports `FormatExchangeRatesForBridgeParams`
- `formatStateForTransactionPay` caches the last result the same way (`isEqual` for rebuilt `accounts` / `nativeAssetIdentifiers`); exports `FormatStateForTransactionPayParams`
- `#getNativeAssetMap` returns a stable empty object when uncached so memoized formatters are not busted by `?? {}` identity churn
- `TokenDataSource` EVM spam filtering now uses per-chain floors from Token API `GET /v1/suggestedOccurrenceFloors` (`queryApiClient.token.fetchV1SuggestedOccurrenceFloors`) instead of a hardcoded minimum of 3 occurrences. Chains missing from the response (or a failed floors fetch) still fall back to 3 ([#9537](https://github.com/MetaMask/core/pull/9537))
- `TokensApiClient` (used by `RpcDataSource` / `TokenDetector`) now sets the token-list `occurrenceFloor` query param from the same Token API `GET /v1/suggestedOccurrenceFloors` endpoint (cached 1h), replacing the hardcoded Linea/MegaETH/Tempo special cases. Missing chains or failed fetches fall back to 3 ([#9537](https://github.com/MetaMask/core/pull/9537))
- Bump `@metamask/network-enablement-controller` from `^5.5.0` to `^5.6.0` ([#9520](https://github.com/MetaMask/core/pull/9520))
Expand Down
8 changes: 6 additions & 2 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ import { processAccountActivityBalanceUpdates } from './utils/processAccountActi

const NATIVE_ASSETS_QUERY_KEY = ['nativeAssets'];

/** Stable empty map so memoized formatters are not busted by `?? {}` identity churn. */
const EMPTY_NATIVE_ASSET_MAP: Record<ChainId, Caip19AssetId> = {};

// ============================================================================
// PENDING TOKEN METADATA (UI input format for addCustomAsset)
// ============================================================================
Expand Down Expand Up @@ -2246,13 +2249,14 @@ export class AssetsController extends BaseController<
* Reads the native asset map (CAIP-2 chain ID -> CAIP-19 native asset ID)
* from the QueryClient cache. Populated at construction via fetchQuery.
*
* @returns Cached map, or empty object if not yet populated.
* @returns Cached map, or a stable empty object if not yet populated
* (stable identity so downstream memoized formatters can cache-hit).
*/
#getNativeAssetMap(): Record<ChainId, Caip19AssetId> {
return (
this.#queryApiClient.getCachedData<Record<ChainId, Caip19AssetId>>(
NATIVE_ASSETS_QUERY_KEY,
) ?? {}
) ?? EMPTY_NATIVE_ASSET_MAP
);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/assets-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ export {
export type {
AccountForLegacyFormat,
BridgeExchangeRatesFormat,
FormatExchangeRatesForBridgeParams,
FormatStateForTransactionPayParams,
LegacyToken,
TransactionPayLegacyFormat,
} from './utils';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { AssetMetadata, FungibleAssetPrice } from '../types';
import { formatExchangeRatesForBridge } from './formatExchangeRatesForBridge';
import {
clearFormatExchangeRatesForBridgeCacheForTesting,
formatExchangeRatesForBridge,
} from './formatExchangeRatesForBridge';

/**
* Builds minimal AssetPrice for tests. Defaults usdPrice to the same
Expand Down Expand Up @@ -390,4 +393,66 @@ describe('formatExchangeRatesForBridge', () => {
'swift:0/iso4217:EUR',
);
});

describe('memoization', () => {
afterEach(() => {
clearFormatExchangeRatesForBridgeCacheForTesting();
});

it('returns the same object when inputs are unchanged by identity / value', () => {
const assetsInfo = {};
const assetsPrice = {
'bip122:000000000019d6689c085ae165831e93/slip44:0': price({
price: 50000,
}),
};
const params = {
assetsInfo,
assetsPrice,
selectedCurrency: 'usd',
nativeAssetIdentifiers: {},
networkConfigurationsByChainId: EVM_NETWORK_CONFIGS,
};

const first = formatExchangeRatesForBridge(params);
const second = formatExchangeRatesForBridge({
...params,
// Rebuilt but deeply equal native map should still hit cache
nativeAssetIdentifiers: {},
});

expect(second).toBe(first);
});

it('recomputes when assetsPrice identity changes', () => {
const assetsInfo = {};
const first = formatExchangeRatesForBridge({
assetsInfo,
assetsPrice: {
'bip122:000000000019d6689c085ae165831e93/slip44:0': price({
price: 50000,
}),
},
selectedCurrency: 'usd',
nativeAssetIdentifiers: {},
});
const second = formatExchangeRatesForBridge({
assetsInfo,
assetsPrice: {
'bip122:000000000019d6689c085ae165831e93/slip44:0': price({
price: 51000,
}),
},
selectedCurrency: 'usd',
nativeAssetIdentifiers: {},
});

expect(second).not.toBe(first);
expect(
second.conversionRates[
'bip122:000000000019d6689c085ae165831e93/slip44:0'
].rate,
).toBe('51000');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@metamask/assets-controllers';
import { Hex, KnownCaipNamespace, numberToHex } from '@metamask/utils';
import { parseCaipAssetType, parseCaipChainId } from '@metamask/utils';
import { isEqual } from 'lodash';

import type {
AssetMetadata,
Expand All @@ -29,27 +30,73 @@ export type BridgeExchangeRatesFormat = {
currentCurrency: string;
};

/** Parameters accepted by {@link formatExchangeRatesForBridge}. */
export type FormatExchangeRatesForBridgeParams = {
assetsInfo: Record<string, AssetMetadata>;
assetsPrice: Record<string, AssetPrice>;
selectedCurrency: string;
nativeAssetIdentifiers: Record<string, string>;
networkConfigurationsByChainId?: Record<string, { nativeCurrency?: string }>;
};

let lastCall: {
params: FormatExchangeRatesForBridgeParams;
result: BridgeExchangeRatesFormat;
} | null = null;

/**
* Converts AssetsController state (assetsPrice, selectedCurrency) into the
* same format the bridge expects from MultichainAssetsRatesController,
* CurrencyRateController, and TokenRatesController so the bridge can use
* a single action when useAssetsControllerForRates is true.
*
* Memoized on input identity for BaseController state slices (`===`) and
* lodash `isEqual` for rebuilt maps (`nativeAssetIdentifiers`). Bridge quote
* / rate paths call this repeatedly while assets state is unchanged; recomputing
* runs keccak256 (`toChecksumAddress`) and CAIP parsing per priced asset.
*
* @param params - Conversion parameters.
* @param params.assetsInfo - Metadata map keyed by CAIP-19 asset ID; used to determine native assets via `type === 'native'`.
* @param params.assetsPrice - Map of CAIP-19 asset ID to price data (must include both `price` and `usdPrice`).
* @param params.selectedCurrency - ISO 4217 currency code (e.g. 'usd').
* @param params.nativeAssetIdentifiers - Map of CAIP-2 chain ID to native asset ID. Used for EVM native lookups.
* @param params.networkConfigurationsByChainId - Optional map of Hex chain ID to network config (e.g. from NetworkController state). Used to resolve native currency symbol via `nativeCurrency`; keys are Hex (e.g. '0x1').
* @returns Bridge-compatible conversionRates, currencyRates, marketData, currentCurrency.
*/
export function formatExchangeRatesForBridge(params: {
assetsInfo: Record<string, AssetMetadata>;
assetsPrice: Record<string, AssetPrice>;
selectedCurrency: string;
nativeAssetIdentifiers: Record<string, string>;
networkConfigurationsByChainId?: Record<string, { nativeCurrency?: string }>;
}): BridgeExchangeRatesFormat {
export function formatExchangeRatesForBridge(
params: FormatExchangeRatesForBridgeParams,
): BridgeExchangeRatesFormat {
if (
lastCall?.params.assetsInfo === params.assetsInfo &&
lastCall.params.assetsPrice === params.assetsPrice &&
lastCall.params.selectedCurrency === params.selectedCurrency &&
lastCall.params.networkConfigurationsByChainId ===
params.networkConfigurationsByChainId &&
isEqual(
lastCall.params.nativeAssetIdentifiers,
params.nativeAssetIdentifiers,
)
) {
return lastCall.result;
}

const result = computeExchangeRatesForBridge(params);
lastCall = { params, result };
return result;
}

/**
* Clears the {@link formatExchangeRatesForBridge} memoize cache. Exported for tests.
*/
export function clearFormatExchangeRatesForBridgeCacheForTesting(): void {
lastCall = null;
}

/**
* Performs the actual exchange-rate conversion for
* {@link formatExchangeRatesForBridge}.
*
* @param params - Conversion parameters.
* @returns Bridge-compatible rates.
*/
function computeExchangeRatesForBridge(
params: FormatExchangeRatesForBridgeParams,
): BridgeExchangeRatesFormat {
const {
assetsInfo,
assetsPrice,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AssetBalance, AssetMetadata, FungibleAssetPrice } from '../types';
import {
clearFormatStateForTransactionPayCacheForTesting,
formatStateForTransactionPay,
AccountForLegacyFormat,
} from './formatStateForTransactionPay';
Expand Down Expand Up @@ -382,4 +383,74 @@ describe('formatStateForTransactionPay', () => {
result.allTokens['0x1'][''].find((token) => token.symbol === 'X'),
).toBeUndefined();
});

describe('memoization', () => {
afterEach(() => {
clearFormatStateForTransactionPayCacheForTesting();
});

it('returns the same object when inputs are unchanged by identity / value', () => {
const assetsBalance = {
[ACCOUNT_1.id]: {
[ETH_NATIVE_ID]: { amount: '100' } as AssetBalance,
},
};
const assetsInfo = {
[ETH_NATIVE_ID]: ETH_NATIVE_METADATA,
};
const assetsPrice = {};
const params = {
accounts: [ACCOUNT_1],
assetsBalance,
assetsInfo,
assetsPrice,
selectedCurrency: 'usd',
nativeAssetIdentifiers: EVM_NATIVE_IDS,
networkConfigurationsByChainId: EVM_NETWORK_CONFIGS,
};

const first = formatStateForTransactionPay(params);
const second = formatStateForTransactionPay({
...params,
accounts: [{ ...ACCOUNT_1 }],
nativeAssetIdentifiers: { ...EVM_NATIVE_IDS },
});

expect(second).toBe(first);
});

it('recomputes when assetsBalance identity changes', () => {
const assetsInfo = {
[ETH_NATIVE_ID]: ETH_NATIVE_METADATA,
};
const first = formatStateForTransactionPay({
accounts: [ACCOUNT_1],
assetsBalance: {
[ACCOUNT_1.id]: {
[ETH_NATIVE_ID]: { amount: '100' } as AssetBalance,
},
},
assetsInfo,
assetsPrice: {},
selectedCurrency: 'usd',
nativeAssetIdentifiers: EVM_NATIVE_IDS,
networkConfigurationsByChainId: EVM_NETWORK_CONFIGS,
});
const second = formatStateForTransactionPay({
accounts: [ACCOUNT_1],
assetsBalance: {
[ACCOUNT_1.id]: {
[ETH_NATIVE_ID]: { amount: '200' } as AssetBalance,
},
},
assetsInfo,
assetsPrice: {},
selectedCurrency: 'usd',
nativeAssetIdentifiers: EVM_NATIVE_IDS,
networkConfigurationsByChainId: EVM_NETWORK_CONFIGS,
});

expect(second).not.toBe(first);
});
});
});
Loading