Skip to content

formatStateForTransactionPay runs keccak256 per asset on every TransactionController:stateChange #9545

Description

@Kureev

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 → onTransactionChangeparseRequiredTokensgetStateForTransactionPayformatStateForTransactionPay, 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

  1. Import a wallet with a large multi-chain token portfolio.
  2. Start a transaction-pay flow (e.g. MetaMask Pay deposit) so transaction-pay-controller subscribes to transaction state.
  3. Profile the JS thread while a transaction is created/approved.
  4. Observe formatStateForTransactionPaytoChecksumAddress → 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 (packgetAddress → keccak256) with no caching (6.5% of samples), so checksum caching at the address level may be worth considering beyond this one function.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions