Skip to content

fix(assets-controller): avoid recomputing getStateForTransactionPay on every transaction state change#9546

Merged
Kureev merged 4 commits into
mainfrom
kureev/memoize-format-state-for-transaction-pay
Jul 21, 2026
Merged

fix(assets-controller): avoid recomputing getStateForTransactionPay on every transaction state change#9546
Kureev merged 4 commits into
mainfrom
kureev/memoize-format-state-for-transaction-pay

Conversation

@Kureev

@Kureev Kureev commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Explanation

formatStateForTransactionPay converts the full AssetsController state into the legacy five-controller shape for transaction-pay-controller, running toChecksumAddress (keccak256) for every asset and parseCaipAssetType for every asset ID on every call. It is exposed as the AssetsController:getStateForTransactionPay messenger action, which transaction-pay-controller invokes on every TransactionController:stateChange — many times during a single transaction approval, and at least twice per event through messenger delegation — while its inputs only change when the assets pipeline updates.

In a CPU profile of MetaMask Mobile (RC) 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, with GC pauses (4.1%) as a symptom of the per-event object churn. See #9545 for the full breakdown.

This change memoizes the last result on input identity: the assetsBalance / assetsInfo / assetsPrice state slices and networkConfigurationsByChainId are compared by reference (BaseController state updates are immutable, so unchanged slices retain identity), selectedCurrency by value, and the per-call-rebuilt accounts array and nativeAssetIdentifiers record element-wise. Repeat calls with unchanged inputs return the cached object; any real input change recomputes. The conversion body is unchanged, moved to an internal computeStateForTransactionPay.

MetaMask Mobile currently carries this fix as a Yarn patch (MetaMask/metamask-mobile#33466), which can be dropped once this lands and a release is picked up.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Low Risk
Performance-only change on a read path; freezing the legacy state object could affect consumers that mutate the return value, though transaction-pay is expected to treat it as read-only.

Overview
Reduces CPU during transaction approval by keeping the last formatStateForTransactionPay result when inputs are unchanged, so AssetsController:getStateForTransactionPay does not repeat expensive toChecksumAddress / CAIP work on every TransactionController:stateChange.

Memoization compares immutable controller slices (assetsBalance, assetsInfo, assetsPrice, networkConfigurationsByChainId) by reference and rebuilt accounts / nativeAssetIdentifiers with lodash isEqual. Parameter equality is centralized in isTransactionPayParamsIdentical. Cached outputs are Object.freezed so in-place mutations cannot poison the cache for later callers.

Tests expand the memoization suite (cache hits, invalidation on balance/accounts/currency changes, frozen results, cache vs recompute parity). Changelog documents the freeze behavior alongside the broader memoization work.

Reviewed by Cursor Bugbot for commit 186e8f3. Bugbot is set up for automated code reviews on this repo. Configure here.

@Kureev Kureev self-assigned this Jul 17, 2026
@Kureev
Kureev marked this pull request as ready for review July 17, 2026 13:56
@Kureev
Kureev requested review from a team as code owners July 17, 2026 13:56
@Kureev
Kureev temporarily deployed to default-branch July 17, 2026 13:56 — with GitHub Actions Inactive
params.nativeAssetIdentifiers,
)
) {
return lastCall.result;

@salimtb salimtb Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit / optional : Cache hits now return the same object reference across calls (previously fresh each time). Since this is a public export, a consumer mutating the result would poison the cache. Worth freezing it before caching:

lastCall = { params, result: Object.freeze(result) };

Comment on lines +68 to +100
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]);
}

@salimtb salimtb Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: lodash is already a dep, so both helpers could just be isEqual (deep, but inputs are tiny/flat so equivalent):

isEqual(lastCall.params.accounts, params.accounts)
isEqual(lastCall.params.nativeAssetIdentifiers, params.nativeAssetIdentifiers)

Only for these rebuilt inputs though — keep the big state slices reference-compared, and avoid _.memoize (unbounded cache).

@salimtb

salimtb commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

nice one , this is a good perf improvement

Comment on lines +139 to +149
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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets move this into a small util: isTransactionPayParamsIdentical()

salimtb
salimtb previously approved these changes Jul 20, 2026
Cal-L
Cal-L previously approved these changes Jul 20, 2026

@Cal-L Cal-L left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Platform LGTM

…-state-for-transaction-pay

# Conflicts:
#	packages/assets-controller/CHANGELOG.md
#	packages/assets-controller/src/index.ts
#	packages/assets-controller/src/utils/formatStateForTransactionPay.test.ts
#	packages/assets-controller/src/utils/formatStateForTransactionPay.ts
@Kureev
Kureev dismissed stale reviews from Cal-L and salimtb via 186e8f3 July 21, 2026 11:12
@Kureev
Kureev requested review from Cal-L and salimtb July 21, 2026 11:12
@Kureev
Kureev enabled auto-merge July 21, 2026 11:14
@Kureev
Kureev added this pull request to the merge queue Jul 21, 2026
Merged via the queue into main with commit c1d7d79 Jul 21, 2026
427 checks passed
@Kureev
Kureev deleted the kureev/memoize-format-state-for-transaction-pay branch July 21, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

formatStateForTransactionPay runs keccak256 per asset on every TransactionController:stateChange

4 participants