Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Memoize `formatStateForTransactionPay` on input identity so repeated `AssetsController:getStateForTransactionPay` calls with unchanged state (one per `TransactionController:stateChange`) no longer re-run per-asset address checksumming (keccak256); also exports the new `FormatStateForTransactionPayParams` type ([#9546](https://github.com/MetaMask/core/pull/9546))
- `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
1 change: 1 addition & 0 deletions packages/assets-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export {
export type {
AccountForLegacyFormat,
BridgeExchangeRatesFormat,
FormatStateForTransactionPayParams,
LegacyToken,
TransactionPayLegacyFormat,
} from './utils';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AssetBalance, AssetMetadata, FungibleAssetPrice } from '../types';
import {
formatStateForTransactionPay,
AccountForLegacyFormat,
FormatStateForTransactionPayParams,
} from './formatStateForTransactionPay';

function price(
Expand Down Expand Up @@ -382,4 +383,92 @@ describe('formatStateForTransactionPay', () => {
result.allTokens['0x1'][''].find((token) => token.symbol === 'X'),
).toBeUndefined();
});

describe('memoization', () => {
const makeParams = (): FormatStateForTransactionPayParams => ({
accounts: [{ ...ACCOUNT_1 }],
assetsBalance: {
[ACCOUNT_1.id]: {
[USDC_ASSET_ID]: { amount: '1000000' } as AssetBalance,
},
},
assetsInfo: {},
assetsPrice: {},
selectedCurrency: 'usd',
nativeAssetIdentifiers: {},
});

it('returns the cached result when called again with identical inputs', () => {
const params = makeParams();

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

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

it('recomputes when a state slice reference changes', () => {
const params = makeParams();

const first = formatStateForTransactionPay(params);
const second = formatStateForTransactionPay({
...params,
assetsBalance: {
[ACCOUNT_1.id]: {
[USDC_ASSET_ID]: { amount: '2000000' } as AssetBalance,
},
},
});

expect(second).not.toBe(first);
const accountLower = ACCOUNT_1.address.toLowerCase();
expect(second.tokenBalances[accountLower]['0x1'][USDC_ADDRESS]).toBe(
'0x1e8480',
);
});

it('recomputes when the accounts change', () => {
const params = makeParams();

const first = formatStateForTransactionPay(params);
const second = formatStateForTransactionPay({
...params,
accounts: [
{
id: 'account-2',
address: '0x0Ac1dF02185025F65202660F8167210A80dD5086',
},
],
});

expect(second).not.toBe(first);
expect(second.tokenBalances).toStrictEqual({});
});

it('recomputes when the selected currency changes', () => {
const params = makeParams();

const first = formatStateForTransactionPay(params);
const second = formatStateForTransactionPay({
...params,
selectedCurrency: 'eur',
});

expect(second).not.toBe(first);
expect(second.currentCurrency).toBe('eur');
});

it('produces the same output whether served from cache or recomputed', () => {
const params = makeParams();

const cached = formatStateForTransactionPay(params);
const recomputed = formatStateForTransactionPay(makeParams());

expect(recomputed).toStrictEqual(cached);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,79 @@ export type TransactionPayLegacyFormat = {
currentCurrency: string;
};

/** Parameters accepted by {@link formatStateForTransactionPay}. */
export type FormatStateForTransactionPayParams = {
assetsBalance: Record<string, Record<string, AssetBalance>>;
assetsInfo: Record<string, AssetMetadata>;
assetsPrice: Record<string, AssetPrice>;
selectedCurrency: string;
accounts: AccountForLegacyFormat[];
nativeAssetIdentifiers: Record<string, string>;
networkConfigurationsByChainId?: Record<string, { nativeCurrency?: string }>;
};

function amountToHex(amount: string): `0x${string}` {
const hexString = BigInt(amount).toString(16);
return `0x${hexString}`;
}

function areAccountsEqual(
a: AccountForLegacyFormat[],
b: AccountForLegacyFormat[],
): boolean {
if (a === b) {
return true;
}
if (a.length !== b.length) {
return false;
}
return a.every(
(account, index) =>
account.id === b[index].id && account.address === b[index].address,
);
}

function areRecordsShallowEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined,
): boolean {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
return aKeys.every((key) => a[key] === b[key]);
}

function getAmountFromBalance(balance: AssetBalance): string {
return typeof balance === 'object' && balance !== null && 'amount' in balance
? String((balance as { amount: string }).amount)
: '0';
}

let lastCall: {
params: FormatStateForTransactionPayParams;
result: TransactionPayLegacyFormat;
} | null = null;

/**
* Converts AssetsController state into the legacy format consumed by
* transaction-pay-controller (TokenBalancesController, AccountTrackerController,
* TokensController, TokenRatesController, CurrencyRateController shapes).
*
* The last result is memoized on input identity: this function is invoked (via
* `AssetsController:getStateForTransactionPay`) on every
* `TransactionController:stateChange`, but its inputs only change when the
* assets pipeline updates. Recomputing runs keccak256 (`toChecksumAddress`)
* per asset per call, which dominates CPU profiles during transaction
* approval.
*
* @param params - Conversion parameters.
* @param params.assetsBalance - Per-account balances by asset ID.
* @param params.assetsInfo - Metadata by asset ID.
Expand All @@ -75,15 +132,40 @@ function getAmountFromBalance(balance: AssetBalance): string {
* @param params.networkConfigurationsByChainId - Optional chain ID to network config (for native symbol).
* @returns Legacy-compatible state for transaction-pay-controller.
*/
export function formatStateForTransactionPay(params: {
assetsBalance: Record<string, Record<string, AssetBalance>>;
assetsInfo: Record<string, AssetMetadata>;
assetsPrice: Record<string, AssetPrice>;
selectedCurrency: string;
accounts: AccountForLegacyFormat[];
nativeAssetIdentifiers: Record<string, string>;
networkConfigurationsByChainId?: Record<string, { nativeCurrency?: string }>;
}): TransactionPayLegacyFormat {
export function formatStateForTransactionPay(
params: FormatStateForTransactionPayParams,
): TransactionPayLegacyFormat {
if (
lastCall?.params.assetsBalance === params.assetsBalance &&
lastCall.params.assetsInfo === params.assetsInfo &&
lastCall.params.assetsPrice === params.assetsPrice &&
lastCall.params.selectedCurrency === params.selectedCurrency &&
lastCall.params.networkConfigurationsByChainId ===
params.networkConfigurationsByChainId &&
areAccountsEqual(lastCall.params.accounts, params.accounts) &&
areRecordsShallowEqual(
lastCall.params.nativeAssetIdentifiers,
params.nativeAssetIdentifiers,
)
) {
return lastCall.result;
}

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

/**
* Performs the actual state conversion for
* {@link formatStateForTransactionPay}.
*
* @param params - Conversion parameters.
* @returns Legacy-compatible state for transaction-pay-controller.
*/
function computeStateForTransactionPay(
params: FormatStateForTransactionPayParams,
): TransactionPayLegacyFormat {
const {
assetsBalance,
assetsInfo,
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controller/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { formatStateForTransactionPay } from './formatStateForTransactionPay';
export type { BridgeExchangeRatesFormat } from './formatExchangeRatesForBridge';
export type {
AccountForLegacyFormat,
FormatStateForTransactionPayParams,
LegacyToken,
TransactionPayLegacyFormat,
} from './formatStateForTransactionPay';
Expand Down