Describe the bug
formatStateForTransactionPay (packages/assets-controller/src/utils/formatStateForTransactionPay.ts) rebuilds the full legacy state shape — including toChecksumAddress (keccak256) for every asset and parseCaipAssetType for every asset ID — on every invocation, with no memoization.
It is exposed as the AssetsController:getStateForTransactionPay messenger action, which transaction-pay-controller calls on every TransactionController:stateChange. Those events fire many times during a single transaction approval, and messenger delegation layers can double the invocations, while the function's actual inputs (assetsBalance / assetsInfo / assetsPrice state slices, selected currency, selected accounts, network configurations) only change when the assets pipeline updates.
Expected behavior
Repeated calls with unchanged inputs should not re-run keccak256 over the entire asset list. A single-slot memo on input identity (state slices and network configurations by reference — BaseController state updates are immutable — currency by value, accounts/native-asset map element-wise) makes repeat calls O(size of the comparison) instead of O(assets × keccak256).
Observed behavior
In a 7.8 s CPU profile of MetaMask Mobile (RC, iPhone Pro Max, multi-chain portfolio) captured while triggering a transaction approval in the MetaMask Pay deposit flow:
- keccak256 via
toChecksumAddress accounted for 10.4% of all samples (~23% of active CPU) — the single largest active bucket; the dominant call chain is _TransactionController_addMetadata → messenger publish → onTransactionChange → parseRequiredTokens → getStateForTransactionPay → formatStateForTransactionPay, with delegatedActionHandler two levels deep in every stack (the computation runs at least twice per event).
- GC pauses accounted for a further 4.1%, consistent with the object churn of rebuilding the full state shape per event.
- A wallet with ~50 tokens across 3 chains pays 50+ keccak256 hashes per state-change event.
Steps to reproduce
- Import a wallet with a large multi-chain token portfolio.
- Start a transaction-pay flow (e.g. MetaMask Pay deposit) so transaction-pay-controller subscribes to transaction state.
- Profile the JS thread while a transaction is created/approved.
- Observe
formatStateForTransactionPay → toChecksumAddress → keccak256 dominating active CPU on every TransactionController:stateChange.
MetaMask Mobile is currently mitigating this with a Yarn patch on @metamask/assets-controller that adds the single-slot memoization described above: MetaMask/metamask-mobile#33466. Upstreaming that (or caching checksummed addresses) would let the patch be removed.
Related, from the same profile: ethers.js ABI encoding during gas estimation re-derives checksums (pack → getAddress → keccak256) with no caching (6.5% of samples), so checksum caching at the address level may be worth considering beyond this one function.
Describe the bug
formatStateForTransactionPay(packages/assets-controller/src/utils/formatStateForTransactionPay.ts) rebuilds the full legacy state shape — includingtoChecksumAddress(keccak256) for every asset andparseCaipAssetTypefor every asset ID — on every invocation, with no memoization.It is exposed as the
AssetsController:getStateForTransactionPaymessenger action, which transaction-pay-controller calls on everyTransactionController:stateChange. Those events fire many times during a single transaction approval, and messenger delegation layers can double the invocations, while the function's actual inputs (assetsBalance/assetsInfo/assetsPricestate slices, selected currency, selected accounts, network configurations) only change when the assets pipeline updates.Expected behavior
Repeated calls with unchanged inputs should not re-run keccak256 over the entire asset list. A single-slot memo on input identity (state slices and network configurations by reference — BaseController state updates are immutable — currency by value, accounts/native-asset map element-wise) makes repeat calls O(size of the comparison) instead of O(assets × keccak256).
Observed behavior
In a 7.8 s CPU profile of MetaMask Mobile (RC, iPhone Pro Max, multi-chain portfolio) captured while triggering a transaction approval in the MetaMask Pay deposit flow:
toChecksumAddressaccounted for 10.4% of all samples (~23% of active CPU) — the single largest active bucket; the dominant call chain is_TransactionController_addMetadata→ messenger publish →onTransactionChange→parseRequiredTokens→getStateForTransactionPay→formatStateForTransactionPay, withdelegatedActionHandlertwo levels deep in every stack (the computation runs at least twice per event).Steps to reproduce
formatStateForTransactionPay→toChecksumAddress→ keccak256 dominating active CPU on everyTransactionController:stateChange.MetaMask Mobile is currently mitigating this with a Yarn patch on
@metamask/assets-controllerthat adds the single-slot memoization described above: MetaMask/metamask-mobile#33466. Upstreaming that (or caching checksummed addresses) would let the patch be removed.Related, from the same profile: ethers.js ABI encoding during gas estimation re-derives checksums (
pack→getAddress→ keccak256) with no caching (6.5% of samples), so checksum caching at the address level may be worth considering beyond this one function.