Skip to content

refactor(transaction-pay-controller): read asset data directly from AssetsController - #10461

Merged
matthewwalsh0 merged 9 commits into
mainfrom
perf/transaction-pay-direct-assets
Sep 29, 2026
Merged

matthewwalsh0 merged 9 commits into
mainfrom
perf/transaction-pay-direct-assets

Conversation

@matthewwalsh0

@matthewwalsh0 matthewwalsh0 commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Explanation

Token metadata, balances, and fiat rates were read from the legacy per-domain asset controllers (TokensController, TokenBalancesController, TokenRatesController, CurrencyRateController, AccountTrackerController), with an assetsUnifyState feature flag switching some of those reads over to a bespoke AssetsController:getStateForTransactionPay action. That meant two parallel read paths, a Pay-specific action on AssetsController, and a subscription to four separate stateChange events to cover whichever source happened to be live.

  • This PR reads token data directly from AssetsController unified state, resolving the account via AccountsController's accountIdByAddress. The feature-flag branching, the Pay-specific getStateForTransactionPay action, and the legacy @metamask/assets-controllers dependency are all no longer used, as is the multi-source subscription, replaced by a single AssetsController:stateChange.
  • Unified state stores human-readable decimal amounts, whereas callers of getTokenBalance expect raw base units, so balances are shifted by the token's decimals.
  • AssetsController becomes the sole source of token metadata. A token absent from it is reported as unknown rather than being reconstructed from network configuration or assumed from a derived identifier, so an unindexed chain is rejected instead of quoting against an assumed symbol and decimals.

Performance

Serving Pay from the legacy shape meant projecting unified state into it, which runs toChecksumAddress — and therefore keccak256 — once per account and once per asset. That projection is memoised on a single entry keyed by input identity, so the cost is paid in full on the first read of each load and again on every read after the assets pipeline updates. Its own docstring notes this dominates CPU profiles during transaction approval.

Reading unified state directly removes that work rather than caching it: Pay no longer builds the legacy projection at all, so no address is checksummed on its behalf. Asset IDs are looked up against the metadata snapshot in hand — a keyed read for ERC-20s, falling back to a scan only for natives and for addresses recovered from calldata in a different case. Because an asset ID is a static property of its chain and address, a resolved ID is cached on those rather than on the state it came from, and confirmed against the current state on reuse so that an asset not yet indexed stays unresolved.

Consumers must delegate AccountsController:getState, AssetsController:getState, and AssetsController:stateChange to the Pay messenger, and must have unified asset state populated before using Pay.

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

High Risk
Breaking integration and payment-path changes: quotes and relay balance checks depend on unified asset indexing and new messenger wiring; misconfigured clients could show wrong balances or fail token resolution.

Overview
Breaking change: Transaction Pay no longer reads token metadata, balances, or fiat rates from the legacy @metamask/assets-controllers stack or from AssetsController:getStateForTransactionPay. Everything goes through AssetsController:getState, with wallet addresses mapped via AccountsController:getState (accountIdByAddress).

The assetsUnifyState feature flag and getAssetsUnifyStateFeature are removed. Asset refresh for in-flight Pay transactions now listens only to AssetsController:stateChange instead of four separate controller events. Dependencies drop @metamask/assets-controllers and add @metamask/accounts-controller.

getTokenBalance, getTokenInfo, and getTokenFiatRate in token.ts are rewritten: balances convert human-readable assetsBalance amounts to raw base units using assetsInfo decimals; prices come from assetsPrice (fungible only, stablecoin USD override preserved). Missing assets are treated as unknown/zero rather than inferred from network tickers. CAIP-19 keys are resolved with a small cache and native-token scan logic.

Tests and messenger mocks are updated to match the new messenger surface; consumers must wire AccountsController:getState, AssetsController:getState, and AssetsController:stateChange and populate unified asset state before Pay runs.

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

@matthewwalsh0
matthewwalsh0 force-pushed the perf/transaction-pay-direct-assets branch from d19a096 to 1d6026a Compare September 25, 2026 08:53
@matthewwalsh0 matthewwalsh0 changed the title refactor(transaction-pay-controller)!: read asset data directly from AssetsController refactor(transaction-pay-controller): read asset data directly from AssetsController Sep 25, 2026
@matthewwalsh0
matthewwalsh0 force-pushed the perf/transaction-pay-direct-assets branch from 003db5e to 6872d7c Compare September 25, 2026 10:51
@matthewwalsh0

Copy link
Copy Markdown
Member Author

@metamaskbot publish-preview

@github-actions

Copy link
Copy Markdown
Contributor

Preview builds have been published. Learn how to use preview builds in other projects.

Expand for full list of packages and versions.
@metamask-previews/account-tree-controller@11.0.0-preview-ff2ab0583
@metamask-previews/accounts-controller@40.0.0-preview-ff2ab0583
@metamask-previews/address-book-controller@8.0.0-preview-ff2ab0583
@metamask-previews/ai-controllers@2.0.0-preview-ff2ab0583
@metamask-previews/analytics-controller@3.2.0-preview-ff2ab0583
@metamask-previews/analytics-data-regulation-controller@0.0.0-preview-ff2ab0583
@metamask-previews/announcement-controller@9.0.0-preview-ff2ab0583
@metamask-previews/app-metadata-controller@3.0.0-preview-ff2ab0583
@metamask-previews/approval-controller@10.0.0-preview-ff2ab0583
@metamask-previews/assets-controller@16.1.2-preview-ff2ab0583
@metamask-previews/assets-controllers@112.0.4-preview-ff2ab0583
@metamask-previews/authenticated-user-storage@4.1.0-preview-ff2ab0583
@metamask-previews/base-controller@10.0.0-preview-ff2ab0583
@metamask-previews/base-data-service@2.0.0-preview-ff2ab0583
@metamask-previews/bitcoin-regtest-up@2.0.0-preview-ff2ab0583
@metamask-previews/bridge-controller@81.3.2-preview-ff2ab0583
@metamask-previews/bridge-status-controller@76.3.2-preview-ff2ab0583
@metamask-previews/build-utils@4.0.0-preview-ff2ab0583
@metamask-previews/chain-agnostic-permission@2.0.0-preview-ff2ab0583
@metamask-previews/chomp-api-service@5.0.0-preview-ff2ab0583
@metamask-previews/claims-controller@1.0.2-preview-ff2ab0583
@metamask-previews/client-controller@2.0.0-preview-ff2ab0583
@metamask-previews/client-utils@3.0.3-preview-ff2ab0583
@metamask-previews/compliance-controller@3.0.0-preview-ff2ab0583
@metamask-previews/composable-controller@13.0.0-preview-ff2ab0583
@metamask-previews/config-registry-controller@4.0.0-preview-ff2ab0583
@metamask-previews/connectivity-controller@1.0.0-preview-ff2ab0583
@metamask-previews/controller-utils@13.0.0-preview-ff2ab0583
@metamask-previews/core-backend@11.0.0-preview-ff2ab0583
@metamask-previews/cryptography@0.0.0-preview-ff2ab0583
@metamask-previews/delegation-controller@4.0.0-preview-ff2ab0583
@metamask-previews/earn-controller@13.0.2-preview-ff2ab0583
@metamask-previews/eip-5792-middleware@4.0.1-preview-ff2ab0583
@metamask-previews/eip-7702-internal-rpc-middleware@1.0.0-preview-ff2ab0583
@metamask-previews/eip1193-permission-middleware@3.0.0-preview-ff2ab0583
@metamask-previews/eth-block-tracker@16.0.0-preview-ff2ab0583
@metamask-previews/eth-json-rpc-middleware@25.0.0-preview-ff2ab0583
@metamask-previews/eth-json-rpc-provider@7.0.0-preview-ff2ab0583
@metamask-previews/foundryup@2.0.0-preview-ff2ab0583
@metamask-previews/gas-fee-controller@27.0.0-preview-ff2ab0583
@metamask-previews/gator-permissions-controller@6.0.1-preview-ff2ab0583
@metamask-previews/geolocation-controller@2.0.0-preview-ff2ab0583
@metamask-previews/java-tron-up@2.0.0-preview-ff2ab0583
@metamask-previews/json-rpc-engine@11.0.0-preview-ff2ab0583
@metamask-previews/json-rpc-middleware-stream@9.0.0-preview-ff2ab0583
@metamask-previews/keyring-controller@28.1.0-preview-ff2ab0583
@metamask-previews/kyc-controller@0.5.0-preview-ff2ab0583
@metamask-previews/local-node-utils@2.0.0-preview-ff2ab0583
@metamask-previews/logging-controller@10.0.0-preview-ff2ab0583
@metamask-previews/message-manager@15.0.0-preview-ff2ab0583
@metamask-previews/messenger@3.0.0-preview-ff2ab0583
@metamask-previews/messenger-cli@1.0.0-preview-ff2ab0583
@metamask-previews/money-account-api-data-service@1.0.0-preview-ff2ab0583
@metamask-previews/money-account-balance-service@3.0.0-preview-ff2ab0583
@metamask-previews/money-account-controller@2.0.0-preview-ff2ab0583
@metamask-previews/money-account-upgrade-controller@5.0.0-preview-ff2ab0583
@metamask-previews/money-account-utils@2.0.1-preview-ff2ab0583
@metamask-previews/multichain-account-service@14.1.0-preview-ff2ab0583
@metamask-previews/multichain-api-middleware@5.0.0-preview-ff2ab0583
@metamask-previews/multichain-network-controller@4.0.0-preview-ff2ab0583
@metamask-previews/multichain-transactions-controller@8.0.0-preview-ff2ab0583
@metamask-previews/name-controller@10.0.0-preview-ff2ab0583
@metamask-previews/network-connection-banner-controller@1.0.0-preview-ff2ab0583
@metamask-previews/network-controller@37.0.0-preview-ff2ab0583
@metamask-previews/network-enablement-controller@7.0.1-preview-ff2ab0583
@metamask-previews/notification-services-controller@29.0.2-preview-ff2ab0583
@metamask-previews/passkey-controller@4.1.0-preview-ff2ab0583
@metamask-previews/permission-controller@14.0.0-preview-ff2ab0583
@metamask-previews/permission-log-controller@6.0.0-preview-ff2ab0583
@metamask-previews/perps-controller@18.0.1-preview-ff2ab0583
@metamask-previews/phishing-controller@18.1.1-preview-ff2ab0583
@metamask-previews/platform-api-docs@0.2.1-preview-ff2ab0583
@metamask-previews/polling-controller@17.0.0-preview-ff2ab0583
@metamask-previews/preferences-controller@24.0.0-preview-ff2ab0583
@metamask-previews/profile-metrics-controller@5.1.2-preview-ff2ab0583
@metamask-previews/profile-sync-controller@33.0.0-preview-ff2ab0583
@metamask-previews/ramps-controller@25.1.1-preview-ff2ab0583
@metamask-previews/rate-limit-controller@8.0.0-preview-ff2ab0583
@metamask-previews/react-data-query@2.0.0-preview-ff2ab0583
@metamask-previews/remote-feature-flag-controller@7.0.0-preview-ff2ab0583
@metamask-previews/sample-controllers@6.0.0-preview-ff2ab0583
@metamask-previews/seedless-onboarding-controller@11.0.1-preview-ff2ab0583
@metamask-previews/selected-network-controller@27.0.0-preview-ff2ab0583
@metamask-previews/sentinel-api-service@2.0.0-preview-ff2ab0583
@metamask-previews/shield-controller@7.0.3-preview-ff2ab0583
@metamask-previews/signature-controller@40.0.0-preview-ff2ab0583
@metamask-previews/smart-transactions-controller@27.0.3-preview-ff2ab0583
@metamask-previews/snap-account-service@4.0.0-preview-ff2ab0583
@metamask-previews/social-controllers@3.2.0-preview-ff2ab0583
@metamask-previews/solana-test-validator-up@2.0.0-preview-ff2ab0583
@metamask-previews/stellar-quickstart-up@0.0.0-preview-ff2ab0583
@metamask-previews/storage-service@2.0.0-preview-ff2ab0583
@metamask-previews/subscription-controller@10.0.1-preview-ff2ab0583
@metamask-previews/transaction-controller@72.0.1-preview-ff2ab0583
@metamask-previews/transaction-pay-controller@29.2.1-preview-ff2ab0583
@metamask-previews/user-operation-controller@42.0.1-preview-ff2ab0583
@metamask-previews/utils@12.0.0-preview-ff2ab0583
@metamask-previews/wallet@15.0.1-preview-ff2ab0583
@metamask-previews/wallet-cli@0.0.0-preview-ff2ab0583

…rom asset state

`getTokenInfo` no longer falls back to the `NetworkController` ticker and a
hardcoded 18 decimals when a native token is absent from `AssetsController`
state, so a chain the wallet has not indexed is reported as unknown instead of
resolving on assumed values.

Native asset IDs are located by scanning the chain's entries for the one marked
`native`, since natives are keyed inconsistently across chains and cannot be
derived. ERC-20 IDs remain derived from the chain and address.

`buildCaipAssetType` is retained for identifiers sent to third parties, such as
Ramps order assets, and documented as such to keep it distinct from lookups
against the wallet's own asset state.

The asset ID cache is keyed on the metadata snapshot it was read from, so it
invalidates itself when `AssetsController` publishes and an asset missing at
first lookup resolves once it appears.
Drop type assertions that oxlint reports as unnecessary and rebaseline the
suppression counts for the test files this branch rewrites.
…sent

An ERC-20 identifier state cannot confirm is no longer returned, matching how a
native absent from state already resolved. The derived identifier only ever
reached a price lookup for an asset with no metadata, since a balance is
unusable without the decimals stored alongside it.

Drop the per-snapshot identifier cache. It was discarded whenever
AssetsController published, so each entry served a handful of lookups, and what
it saved was a string comparison rather than the per-asset keccak256 the legacy
projection ran.

Read balance decimals from the metadata snapshot already in hand rather than
re-entering AssetsController state.
…nd address

An asset ID is a static property of its chain and address, so one resolved from
any snapshot stays correct and is keyed by chain and lower-cased address rather
than scoped to the state it came from.

A cached ID is confirmed against the given state before reuse, since an asset
AssetsController has not indexed yet is unresolvable however its ID is spelled.
That check is a keyed read, so the scan is still skipped. Only resolved IDs are
cached, so an asset missing at first lookup is picked up once it appears.
…onstant

NATIVE_TOKEN_DECIMALS backed the native token fallback, which no longer exists
now that decimals are read from AssetsController state.
…onfig references

The package swapped the legacy assets-controllers dependency for
accounts-controller, which the repo's generated README dependency graph and
tsconfig.lint.json project references are both derived from.
@matthewwalsh0
matthewwalsh0 force-pushed the perf/transaction-pay-direct-assets branch from ff2ab05 to 3d5bb62 Compare September 28, 2026 09:42
@matthewwalsh0

Copy link
Copy Markdown
Member Author

@metamaskbot publish-preview

…g indexing

assets-controller v17 narrows the assetsInfo key to a CAIP-19 template
literal, so indexing it with a `for...in` key (typed `string`) produced an
implicit `any` under noImplicitAny and an unsafe-member-access lint error.

Iterate with Object.entries/Object.keys so the value is read directly rather
than through an index operation.
@matthewwalsh0
matthewwalsh0 marked this pull request as ready for review September 28, 2026 13:56
@matthewwalsh0
matthewwalsh0 requested review from a team as code owners September 28, 2026 13:56
@matthewwalsh0
matthewwalsh0 added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 4d56ed1 Sep 29, 2026
345 checks passed
@matthewwalsh0
matthewwalsh0 deleted the perf/transaction-pay-direct-assets branch September 29, 2026 09:02
@cursor cursor Bot mentioned this pull request Sep 29, 2026
pull Bot pushed a commit to Reality2byte/metamask-mobile that referenced this pull request Sep 29, 2026
…etaMask#36889)

## Description

Bumps `transaction-pay-controller` to `^30.0.0`
([MetaMask/core#10461](MetaMask/core#10461),
released in
[MetaMask/core#10565](MetaMask/core#10565)),
which reads token metadata, balances, and prices straight from
`AssetsController` rather than the legacy projection assembled from five
separate controllers.

The messenger now delegates `AccountsController:getState` and
`AssetsController:getState` in place of the `AccountTrackerController`,
`CurrencyRateController`, `TokenBalancesController`,
`TokenRatesController`, and `TokensController` reads that backed
`AssetsController:getStateForTransactionPay`. It also delegates
`AssetsController:stateChange`, which the controller now subscribes to
in place of the separate per-controller asset state events.

### Dependencies

No resolutions are needed: main already declares the versions the
controller requires (`accounts-controller` ^40, `assets-controller` ^17,
`ramps-controller` ^26.0.1), so adopting it introduces no duplicate
`@metamask` packages.

## Checklist

- [x] Typecheck clean (only pre-existing failures from the gitignored
generated `termsOfUseContent`)
- [x] Messenger action union verified to resolve to its 33 real actions
rather than `any`
- [x] 701 Pay-related tests pass across 52 suites

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> MetaMask Pay now depends on a single AssetsController surface for
balances, rates, and tokens; regressions could affect pay estimates or
fiat flows if asset state diverges from the old multi-controller
projection.
> 
> **Overview**
> Upgrades **`@metamask/transaction-pay-controller`** to **^30.0.0** and
aligns the mobile **`TransactionPayController`** messenger with the new
asset data path.
> 
> **Messenger delegation** now wires **`AccountsController:getState`**
and **`AssetsController:getState`** plus
**`AssetsController:stateChange`**, instead of the previous bundle
(`AccountTrackerController`, `CurrencyRateController`,
`TokenBalancesController`, `TokenRatesController`, `TokensController`,
and **`AssetsController:getStateForTransactionPay`**). A unit test
asserts those **`AssetsController`** action and event delegations.
> 
> Lockfile changes follow the bumped controller and its transitive deps
(e.g. **`assets-controller` ^17**, **`ramps-controller` ^26**).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ab14eb2. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
pull Bot pushed a commit to firas9941/metamask-extension that referenced this pull request Sep 30, 2026
…etaMask#46706)

## **Description**

Bumps `transaction-pay-controller` to `^30.0.0`
([MetaMask/core#10461](MetaMask/core#10461),
released in
[MetaMask/core#10565](MetaMask/core#10565)),
which reads token metadata, balances, and prices straight from
`AssetsController` rather than the legacy projection assembled from five
separate controllers.

The messenger now delegates `AccountsController:getState` and
`AssetsController:getState` in place of the `AccountTrackerController`,
`CurrencyRateController`, `TokenBalancesController`,
`TokenRatesController`, and `TokensController` reads that backed
`AssetsController:getStateForTransactionPay`. The controller now
subscribes only to `AssetsController:stateChange` (already delegated),
so the `CurrencyRateController`, `TokenRatesController`, and
`TokensController` state events are dropped.

### Why `ramps-controller` is bumped

Extension was three majors behind, so this also absorbs the v29 Ramps
`getQuotes` -> `getQuoteWithFees` rename. `ramps-controller` is bumped
to `^26.0.1` and its `^22` resolution removed. Bumping only Pay leaves
that resolution forcing it onto `ramps-controller` 22, which has no
`getQuoteWithFees`:

- At runtime, the fiat strategy's `RampsController:getQuoteWithFees`
call fails.
- At compile time, the import sits in a `.d.ts`, so `skipLibCheck`
resolves it to `any`. That collapses the UI messenger's action union to
plain `string`, surfacing only as two `Unused '@ts-expect-error'` errors
in `ui-messenger.test.ts`.

Ramps 26's own breaking change adds
`KycController:getProviderFlowStatus` to
`RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. It is only called on the
VBA onboarding path, like the existing `KycController` actions extension
already delegates via that constant, so no wiring changes are needed.

No other dependencies or resolutions change. Pay declares
`accounts-controller` `^40.0.0`, but extension's existing `^39.1.0`
resolution is kept: 40.0.0 only drops CommonJS and bumps Node/ES
targets, with no API change.

## **Changelog**

CHANGELOG entry: null

## **Related issues**

Depends on: MetaMask/core#10461, released in MetaMask/core#10565 as
30.0.0

## **Manual testing steps**

1. Run the extension and open a Pay confirmation (deposit or withdraw).
2. Verify the payment token list shows correct balances and fiat values.
3. Switch the payment token and verify the quote and fees update.
4. Verify a token with a zero balance still appears with the correct
metadata.

## **Screenshots/Recordings**

N/A — no visual change; this is an internal state-source refactor.

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability.
- [x] I've included tests if applicable.
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable.
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).

This branch was successfully deployed

1 active deployment
default-branch — 72691e4f Deployed Sep 28, 2026 by matthewwalsh0 via Determine whether this PR is a release PR #4771
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants