Skip to content

Commit 212cbd0

Browse files
runway-github[bot]Matt561joaoloureirop
authored
chore(runway): cherry-pick fix: MUSD-266 staked ethereum balance mismatch (#25580)
- fix: cp-7.64.0 MUSD-266 staked ethereum balance mismatch (#25468) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Fixed bug where Staked Ethereum balances weren't updating when switching accounts. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: fixed bug where Staked Ethereum balances weren't updating when switching accounts. ## **Related issues** Fixes: [MUSD-266: Staked Ethereum Balance Mismatch](https://consensyssoftware.atlassian.net/browse/MUSD-266) ## **Manual testing steps** ```gherkin Feature: Correct staked asset balances per account Scenario: user switches accounts and sees staked Ethereum balance update Given user has multiple EVM accounts with different Ethereum and Staked Ethereum balances When user switches the selected account Then the displayed Ethereum balance matches the selected account And the displayed Staked Ethereum balance matches the selected account Scenario: user views a staked token and sees the staked balance (not the native counterpart) Given user has a staked token balance When user opens the token details view for the staked token Then the displayed balance corresponds to the staked asset ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> 1. Staked Ethereum doesn't update when switching accounts 2. Staked Ethereum AssetOverview was displaying the user's **native** ETH balance ### **After** <!-- [screenshots/recordings] --> 1. Staked Ethereum now updates when switching accounts 2. Staked Ethereum on balance in TokenListItem now matches balance on AssetOverview screen https://github.com/user-attachments/assets/8ba8ed5b-b499-4739-8b8b-895d1a09a47c ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/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-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core `selectAsset` lookup behavior for EVM chains and alters displayed fiat formatting/rounding for staked balances, which could impact multiple balance/asset UI surfaces when switching accounts. > > **Overview** > Fixes a bug where **Staked Ethereum** could display the wrong account’s balance after switching accounts by scoping `selectAsset` results (native and staked) to `selectSelectedInternalAccountId` on EVM chains. > > Updates `useBalance` to source staked native asset fiat values from `selectAsset` (matching the token list’s Intl formatting) with a `weiToFiat` fallback, and adjusts/extends tests accordingly (including new coverage for account-scoped lookups and updated expected fiat strings). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 32c383d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [aeee852](aeee852) Co-authored-by: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com>
1 parent 40c829e commit 212cbd0

5 files changed

Lines changed: 180 additions & 17 deletions

File tree

app/components/UI/Earn/hooks/useEarnWithdrawInput.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jest.mock('./useInput');
1919
jest.mock('./useEarnGasFee');
2020

2121
jest.mock('../../../../selectors/currencyRateController', () => ({
22+
selectCurrencyRates: jest.fn(() => ({})),
2223
selectCurrentCurrency: jest.fn(() => 'USD'),
2324
}));
2425

app/components/UI/Stake/hooks/useBalance.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('useBalance', () => {
9797
expect(result.current.stakedBalanceWei).toBe('5791332670714232000'); // No staked assets
9898
expect(result.current.formattedStakedBalanceETH).toBe('5.79133 ETH'); // Formatted ETH balance
9999
expect(result.current.stakedBalanceFiatNumber).toBe(18532.26454); // Staked balance in fiat number
100-
expect(result.current.formattedStakedBalanceFiat).toBe('$18532.26'); //
100+
expect(result.current.formattedStakedBalanceFiat).toBe('$18,532.26'); // Intl-formatted fiat
101101
});
102102

103103
it('returns default values when no selected address and no account data', async () => {
@@ -185,10 +185,10 @@ describe('useBalance', () => {
185185
expect(result.current.stakedBalanceWei).toBe('99999999990000000000000'); // No staked assets
186186
expect(result.current.formattedStakedBalanceETH).toBe('99999.99999 ETH'); // Formatted ETH balance
187187
expect(result.current.stakedBalanceFiatNumber).toBe(319999999.968); // Staked balance in fiat number
188-
expect(result.current.formattedStakedBalanceFiat).toBe('$319999999.96'); // should round to floor
188+
expect(result.current.formattedStakedBalanceFiat).toBe('$319,999,999.97'); // Intl-formatted fiat
189189
});
190190

191-
it('returns correct stake amounts and fiat values when chainId is overriden', async () => {
191+
it('returns correct stake amounts and fiat values when chainId is overridden', async () => {
192192
const { result } = renderHookWithProvider(() => useBalance('0x4268'), {
193193
state: initialState,
194194
});
@@ -202,6 +202,6 @@ describe('useBalance', () => {
202202
expect(result.current.stakedBalanceWei).toBe('5791332670714232000');
203203
expect(result.current.formattedStakedBalanceETH).toBe('5.79133 ETH'); // Formatted ETH balance
204204
expect(result.current.stakedBalanceFiatNumber).toBe(18532.26454); // Staked balance in fiat number
205-
expect(result.current.formattedStakedBalanceFiat).toBe('$18532.26'); //
205+
expect(result.current.formattedStakedBalanceFiat).toBe('$18532.26'); // Fallback formatting when selector has no staked asset for chain
206206
});
207207
});

app/components/UI/Stake/hooks/useBalance.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { useMemo } from 'react';
22
import { useSelector } from 'react-redux';
33
import { Hex } from '@metamask/utils';
4+
import { getNativeTokenAddress } from '@metamask/assets-controllers';
45
import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts';
56
import { selectAccountsByChainId } from '../../../../selectors/accountTrackerController';
67
import {
78
selectCurrencyRates,
89
selectCurrentCurrency,
910
} from '../../../../selectors/currencyRateController';
1011
import { selectEvmChainId } from '../../../../selectors/networkController';
12+
import { RootState } from '../../../../reducers';
1113
import {
1214
hexToBN,
1315
renderFromWei,
@@ -16,6 +18,7 @@ import {
1618
} from '../../../../util/number';
1719
import { getFormattedAddressFromInternalAccount } from '../../../../core/Multichain/utils';
1820
import { EVM_SCOPE } from '../../Earn/constants/networks';
21+
import { selectAsset } from '../../../../selectors/assets/assets-list';
1922

2023
const useBalance = (chainId?: Hex) => {
2124
const accountsByChainId = useSelector(selectAccountsByChainId);
@@ -68,18 +71,30 @@ const useBalance = (chainId?: Hex) => {
6871
[stakedBalance, conversionRate],
6972
);
7073

71-
const formattedStakedBalanceFiat = useMemo(
72-
() =>
73-
weiToFiat(
74-
// TODO: Replace "any" with type
75-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
76-
hexToBN(stakedBalance) as any,
77-
conversionRate,
78-
currentCurrency,
79-
),
80-
[currentCurrency, stakedBalance, conversionRate],
74+
const stakedNativeAssetBalanceFiat = useSelector(
75+
(state: RootState) =>
76+
selectAsset(state, {
77+
address: getNativeTokenAddress(balanceChainId),
78+
chainId: balanceChainId,
79+
isStaked: true,
80+
})?.balanceFiat,
8181
);
8282

83+
const formattedStakedBalanceFiat = useMemo(() => {
84+
// Match the fiat balance seen in the asset list.
85+
// Fallback to the weiToFiat function if the staked native asset balance fiat is not available.
86+
if (stakedNativeAssetBalanceFiat) {
87+
return stakedNativeAssetBalanceFiat;
88+
}
89+
90+
return weiToFiat(hexToBN(stakedBalance), conversionRate, currentCurrency);
91+
}, [
92+
conversionRate,
93+
currentCurrency,
94+
stakedBalance,
95+
stakedNativeAssetBalanceFiat,
96+
]);
97+
8398
return {
8499
balanceETH,
85100
balanceFiat,

app/selectors/assets/assets-list.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,130 @@ describe('selectAsset', () => {
733733
});
734734
});
735735

736+
it('scopes native and staked lookups to selected account', () => {
737+
const stateWithSecondEvm = mockState();
738+
const account1Id =
739+
stateWithSecondEvm.engine.backgroundState.AccountsController
740+
.internalAccounts.selectedAccount;
741+
742+
const account2Id = '11111111-1111-1111-1111-111111111111';
743+
const account2Address = '0x1111111111111111111111111111111111111111';
744+
const account2AddressLowercased = account2Address.toLowerCase();
745+
746+
const withSelectedAccount = (
747+
state: RootState,
748+
selectedAccount: string,
749+
): RootState => ({
750+
...state,
751+
engine: {
752+
...state.engine,
753+
backgroundState: {
754+
...state.engine.backgroundState,
755+
AccountsController: {
756+
...state.engine.backgroundState.AccountsController,
757+
internalAccounts: {
758+
...state.engine.backgroundState.AccountsController
759+
.internalAccounts,
760+
selectedAccount,
761+
},
762+
},
763+
},
764+
},
765+
});
766+
767+
// Add second EVM internal account into the same selected account group
768+
stateWithSecondEvm.engine.backgroundState.AccountsController.internalAccounts.accounts[
769+
account2Id
770+
] = {
771+
id: account2Id,
772+
address: account2Address,
773+
options: {},
774+
methods: [],
775+
scopes: ['eip155:0'],
776+
type: 'eip155:eoa',
777+
metadata: {
778+
name: 'Account 2',
779+
importTime: 0,
780+
keyring: {
781+
type: 'HD Key Tree',
782+
},
783+
},
784+
};
785+
786+
const groupId = 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/0';
787+
const walletId = 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ';
788+
stateWithSecondEvm.engine.backgroundState.AccountTreeController.accountTree.wallets[
789+
walletId
790+
].groups[groupId].accounts = [
791+
...stateWithSecondEvm.engine.backgroundState.AccountTreeController
792+
.accountTree.wallets[walletId].groups[groupId].accounts,
793+
account2Id,
794+
];
795+
796+
// Provide AccountTracker balances for second address on mainnet
797+
stateWithSecondEvm.engine.backgroundState.AccountTrackerController.accountsByChainId[
798+
'0x1'
799+
][account2AddressLowercased] = {
800+
balance: '0x0DE0B6B3A7640000', // 1 ETH
801+
stakedBalance: '0x1BC16D674EC80000', // 2 ETH
802+
};
803+
804+
// Provide empty token lists/balances for second address to keep asset building stable
805+
stateWithSecondEvm.engine.backgroundState.TokensController.allTokens['0x1'][
806+
account2AddressLowercased
807+
] = [];
808+
stateWithSecondEvm.engine.backgroundState.TokensController.allTokens['0xa'][
809+
account2AddressLowercased
810+
] = [];
811+
(
812+
stateWithSecondEvm.engine.backgroundState.TokenBalancesController
813+
.tokenBalances as Record<string, unknown>
814+
)[account2AddressLowercased] = {};
815+
816+
// Sanity check: original account still resolves correctly
817+
const stateForAccount1 = withSelectedAccount(
818+
stateWithSecondEvm,
819+
account1Id,
820+
);
821+
822+
const stakedForAccount1 = selectAsset(stateForAccount1, {
823+
address: '0x0000000000000000000000000000000000000000',
824+
chainId: '0x1',
825+
isStaked: true,
826+
});
827+
expect(stakedForAccount1?.balance).toBe('100');
828+
829+
// Switch selected account → balances should follow
830+
const stateForAccount2 = withSelectedAccount(
831+
stateWithSecondEvm,
832+
account2Id,
833+
);
834+
835+
const nativeForAccount2 = selectAsset(stateForAccount2, {
836+
address: '0x0000000000000000000000000000000000000000',
837+
chainId: '0x1',
838+
isStaked: false,
839+
});
840+
expect(nativeForAccount2).toMatchObject({
841+
name: 'Ethereum',
842+
balance: '1',
843+
balanceFiat: '$2,400.00',
844+
isStaked: false,
845+
});
846+
847+
const stakedForAccount2 = selectAsset(stateForAccount2, {
848+
address: '0x0000000000000000000000000000000000000000',
849+
chainId: '0x1',
850+
isStaked: true,
851+
});
852+
expect(stakedForAccount2).toMatchObject({
853+
name: 'Staked Ethereum',
854+
balance: '2',
855+
balanceFiat: '$4,800.00',
856+
isStaked: true,
857+
});
858+
});
859+
736860
it('returns formatted evm token asset based on filter criteria', () => {
737861
const state = mockState();
738862
const result = selectAsset(state, {

app/selectors/assets/assets-list.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import {
2828
} from '../../core/Multichain/constants';
2929
import { sortAssetsWithPriority } from '../../components/UI/Tokens/util/sortAssetsWithPriority';
3030
import { selectAllTokens } from '../tokensController';
31-
import { selectSelectedInternalAccountAddress } from '../accountsController';
31+
import {
32+
selectSelectedInternalAccountAddress,
33+
selectSelectedInternalAccountId,
34+
} from '../accountsController';
3235

3336
const getStateForAssetSelector = (state: RootState) => {
3437
const {
@@ -264,6 +267,7 @@ export const selectAsset = createSelector(
264267
state.engine.backgroundState.TokenListController.tokensChainsCache,
265268
selectAllTokens,
266269
selectSelectedInternalAccountAddress,
270+
selectSelectedInternalAccountId,
267271
(
268272
_state: RootState,
269273
params: { address: string; chainId: string; isStaked?: boolean },
@@ -283,20 +287,39 @@ export const selectAsset = createSelector(
283287
tokensChainsCache,
284288
allTokens,
285289
selectedAddress,
290+
selectedAccountId,
286291
address,
287292
chainId,
288293
isStaked,
289294
) => {
295+
/**
296+
* Note: Without this, the selector would return the wrong asset for the selected account on EVM chains.
297+
* This caused Staked Ethereum to not update when switching accounts.
298+
* We want to apply this to EVM chains only.
299+
*/
300+
const shouldScopeToSelectedAccount =
301+
Boolean(selectedAccountId) && typeof chainId === 'string'
302+
? chainId.startsWith('0x')
303+
: false;
304+
290305
const asset = isStaked
291306
? stakedAssets.find(
292307
(item) =>
293-
item.chainId === chainId && item.stakedAsset.assetId === address,
308+
item.chainId === chainId &&
309+
(!shouldScopeToSelectedAccount ||
310+
item.accountId === selectedAccountId) &&
311+
item.stakedAsset.assetId === address,
294312
)?.stakedAsset
295313
: assets[chainId]?.find((item: Asset & { isStaked?: boolean }) => {
296314
// Normalize isStaked values: treat undefined as false
297315
const itemIsStaked = Boolean(item.isStaked);
298316
const targetIsStaked = Boolean(isStaked);
299-
return item.assetId === address && itemIsStaked === targetIsStaked;
317+
return (
318+
item.assetId === address &&
319+
(!shouldScopeToSelectedAccount ||
320+
item.accountId === selectedAccountId) &&
321+
itemIsStaked === targetIsStaked
322+
);
300323
});
301324

302325
// Look up rwaData from the original token in allTokens

0 commit comments

Comments
 (0)