From 17ad80d8d90631cb1032cc174de8471d23961da2 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 30 Jul 2026 16:53:32 +0200 Subject: [PATCH 1/3] fix(assets-controller): preserve staked balances on Accounts API updates Exclude staking vault asset IDs from Accounts API v5/v6 balance processing and keep prior staked amounts when replaceCoveredChainBalances would drop them, so network switches no longer reset pooled staking to missing/0. Co-authored-by: Cursor --- packages/assets-controller/CHANGELOG.md | 1 + .../src/AssetsController.test.ts | 41 ++++++++++++ .../assets-controller/src/AssetsController.ts | 13 ++++ .../AccountsApiDataSource.test.ts | 65 +++++++++++++++++++ .../src/data-sources/AccountsApiDataSource.ts | 15 +++++ 5 files changed, 135 insertions(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 9aa1bbe851f..536fd92d468 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Preserve pooled-staking balances across Accounts API chain-slice updates (e.g. network switch / `replaceCoveredChainBalances`): exclude staking contract asset IDs from `AccountsApiDataSource` v5/v6 balance processing, and keep prior staked amounts when a merge replace omits them so Accounts API cannot reset staked ETH to missing/0 - `withTrace` treats a rejected parent `trace` promise as best-effort (like `emitTrace`), so Sentry/adapter failures cannot fail full fetches or `handleAssetsUpdate` enrichment ([#9672](https://github.com/MetaMask/core/pull/9672)) - `SnapDataSource` now delivers snap-sourced balance updates directly to `AssetsController` via a constructor-supplied `onAssetsUpdate` callback instead of fanning out to `activeSubscriptions`, so updates (e.g. Tron energy/bandwidth) are no longer dropped when no active subscription is tracked for the chain in the SnapDataSource ([#9656](https://github.com/MetaMask/core/pull/9656)) - Balance aggregation selectors (`getAggregatedBalanceForAccount`, `getAggregatedBalanceForAccountIds`, `calculateBalanceForAllWallets`, `calculateBalanceChangeForAccountGroup`) no longer rescale balances whose amount is greater than or equal to `10^decimals`. Amounts in `assetsBalance` state are always human-readable, so the removed raw-vs-human magnitude heuristic corrupted legitimately large balances (e.g. 54.06B tokens with 9 decimals were divided by `10^9`), excluding them from aggregated fiat totals ([#9653](https://github.com/MetaMask/core/pull/9653)) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index a0476a5e145..075b220955f 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2493,6 +2493,47 @@ describe('AssetsController', () => { }); }); + it('preserves existing staked balances when replaceCoveredChainBalances omits them', async () => { + const stakingAssetId = + 'eip155:1/erc20:0x4FEF9D741011476750A243aC70b9789a63dd47Df' as Caip19AssetId; + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, + [stakingAssetId]: { amount: '1.5' }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'merge', + replaceCoveredChainBalances: true, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'AccountsApiDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeUndefined(); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '2' }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[stakingAssetId], + ).toStrictEqual({ amount: '1.5' }); + }); + }); + it('replaces state when full update has authoritative data', async () => { const initialState: Partial = { assetsBalance: { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 7b4dd199139..cee1e2daa56 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -81,6 +81,7 @@ import type { import { AccountActivityDataSource } from './data-sources/AccountActivityDataSource.js'; import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource.js'; import { AccountsApiDataSource } from './data-sources/AccountsApiDataSource.js'; +import { isStakingContractAssetId } from './data-sources/evm-rpc-services/index.js'; import { shouldSkipNativeForCaipChainId } from './data-sources/evm-rpc-services/utils/assets.js'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource.js'; import { @@ -608,6 +609,18 @@ function mergeAccountBalances( } } + // Staked vault balances are owned by StakedBalanceDataSource. When an + // Accounts API (or other) chain-slice replace omits them, keep the prior + // on-chain staked amount instead of clearing it to missing/0. + for (const [assetId, balance] of Object.entries(previousBalances)) { + if ( + isStakingContractAssetId(assetId) && + !Object.prototype.hasOwnProperty.call(next, assetId) + ) { + next[assetId] = balance; + } + } + return next; } diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 0bec4e86f9e..2e20e0ebc3e 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -565,6 +565,37 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('excludes staking contract asset IDs from v5 balance response', async () => { + const stakingAssetId = + 'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df'; + const balances = [ + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + 'eip155:1/slip44:60', + '1000000000000000000', + ), + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + stakingAssetId, + '0', + ), + ]; + + const { controller } = await setupController({ balances }); + + const response = await controller.fetch(createDataRequest()); + const accountBalances = response.assetsBalance?.['mock-account-id'] ?? {}; + + expect(accountBalances).toHaveProperty('eip155:1/slip44:60'); + expect( + Object.keys(accountBalances).some((id) => + id.toLowerCase().includes('0x4fef9d741011476750a243ac70b9789a63dd47df'), + ), + ).toBe(false); + + controller.destroy(); + }); + it('fetch marks unprocessed networks as errors', async () => { const { controller } = await setupController({ unprocessedNetworks: ['eip155:1'], @@ -755,6 +786,40 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('excludes staking contract asset IDs from v6 balance response', async () => { + const stakingAssetId = + 'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df'; + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + v6Accounts: [ + { + accountId: `eip155:1:${MOCK_ADDRESS}`, + balances: [ + createMockV6BalanceItem( + 'eip155:1/slip44:60', + '1000000000000000000', + ), + createMockV6BalanceItem(stakingAssetId, '0'), + ], + }, + ], + }); + + const response = await controller.fetch(createDataRequest()); + const accountBalances = response.assetsBalance?.['mock-account-id'] ?? {}; + + expect(accountBalances).toHaveProperty('eip155:1/slip44:60'); + expect( + Object.keys(accountBalances).some((id) => + id + .toLowerCase() + .includes('0x4fef9d741011476750a243ac70b9789a63dd47df'), + ), + ).toBe(false); + + controller.destroy(); + }); + it('marks v6 unprocessed networks as errors', async () => { const { controller } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 95339c45fc5..3de2633e958 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -34,6 +34,7 @@ import type { SubscriptionRequest, } from './AbstractDataSource.js'; import { AbstractDataSource } from './AbstractDataSource.js'; +import { isStakingContractAssetId } from './evm-rpc-services/index.js'; // ============================================================================ // CONSTANTS @@ -612,6 +613,13 @@ export class AccountsApiDataSource extends AbstractDataSource< // Normalize asset ID (checksum EVM addresses for ERC20 tokens) const normalizedAssetId = normalizeAssetId(item.assetId as Caip19AssetId); + // Staked balances are owned by StakedBalanceDataSource. Accounts API may + // return the vault share token as a normal ERC-20 (often 0 or stale), + // which would overwrite or wipe the on-chain staked amount on merge. + if (isStakingContractAssetId(normalizedAssetId)) { + continue; + } + // Store balance as returned by API assetsBalance[accountId][normalizedAssetId] = { amount: item.balance, @@ -678,6 +686,13 @@ export class AccountsApiDataSource extends AbstractDataSource< item.assetId as Caip19AssetId, ); + // Staked balances are owned by StakedBalanceDataSource. Accounts API may + // return the vault share token as a normal ERC-20 (often 0 or stale), + // which would overwrite or wipe the on-chain staked amount on merge. + if (isStakingContractAssetId(normalizedAssetId)) { + continue; + } + // Store balance as returned by API assetsBalance[accountId][normalizedAssetId] = { amount: item.balance, From 6a9595c2ad0203c6675993a9245640a028e96166 Mon Sep 17 00:00:00 2001 From: salimtb Date: Mon, 3 Aug 2026 10:07:02 +0200 Subject: [PATCH 2/3] docs(assets-controller): move staked-balance changelog entry to Unreleased The entry was mistakenly placed in the released 11.3.0 section; move it under Unreleased and add the PR link (#9753). Co-authored-by: Cursor --- packages/assets-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 536fd92d468..2d188ce015c 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/phishing-controller` from `^17.3.0` to `^17.3.1` ([#9746](https://github.com/MetaMask/core/pull/9746)) +### Fixed + +- Preserve pooled-staking balances across Accounts API chain-slice updates (e.g. network switch / `replaceCoveredChainBalances`): exclude staking contract asset IDs from `AccountsApiDataSource` v5/v6 balance processing, and keep prior staked amounts when a merge replace omits them so Accounts API cannot reset staked ETH to missing/0 ([#9753](https://github.com/MetaMask/core/pull/9753)) + ## [13.1.0] ### Added @@ -82,7 +86,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Preserve pooled-staking balances across Accounts API chain-slice updates (e.g. network switch / `replaceCoveredChainBalances`): exclude staking contract asset IDs from `AccountsApiDataSource` v5/v6 balance processing, and keep prior staked amounts when a merge replace omits them so Accounts API cannot reset staked ETH to missing/0 - `withTrace` treats a rejected parent `trace` promise as best-effort (like `emitTrace`), so Sentry/adapter failures cannot fail full fetches or `handleAssetsUpdate` enrichment ([#9672](https://github.com/MetaMask/core/pull/9672)) - `SnapDataSource` now delivers snap-sourced balance updates directly to `AssetsController` via a constructor-supplied `onAssetsUpdate` callback instead of fanning out to `activeSubscriptions`, so updates (e.g. Tron energy/bandwidth) are no longer dropped when no active subscription is tracked for the chain in the SnapDataSource ([#9656](https://github.com/MetaMask/core/pull/9656)) - Balance aggregation selectors (`getAggregatedBalanceForAccount`, `getAggregatedBalanceForAccountIds`, `calculateBalanceForAllWallets`, `calculateBalanceChangeForAccountGroup`) no longer rescale balances whose amount is greater than or equal to `10^decimals`. Amounts in `assetsBalance` state are always human-readable, so the removed raw-vs-human magnitude heuristic corrupted legitimately large balances (e.g. 54.06B tokens with 9 decimals were divided by `10^9`), excluding them from aggregated fiat totals ([#9653](https://github.com/MetaMask/core/pull/9653)) From 3696566add4a03045ff06399a34bc3092c320ea4 Mon Sep 17 00:00:00 2001 From: salimtb Date: Mon, 3 Aug 2026 10:19:30 +0200 Subject: [PATCH 3/3] style(assets-controller): fix Prettier formatting in AccountsApiDataSource test Co-authored-by: Cursor --- .../src/data-sources/AccountsApiDataSource.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 2e20e0ebc3e..68bf5896a07 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -574,11 +574,7 @@ describe('AccountsApiDataSource', () => { 'eip155:1/slip44:60', '1000000000000000000', ), - createMockBalanceItem( - `eip155:1:${MOCK_ADDRESS}`, - stakingAssetId, - '0', - ), + createMockBalanceItem(`eip155:1:${MOCK_ADDRESS}`, stakingAssetId, '0'), ]; const { controller } = await setupController({ balances });