From f5ad522451002eb2b56fdac78d2b11ad26ac471a Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Mon, 4 Nov 2024 18:01:28 -0500 Subject: [PATCH] feat: Show Staked ETH position in mobile homepage along with other tokens (#4879) ## Explanation **What is the current state of things and why does it need to change?** - Currently the `metamask-mobile` app using the `assets-controllers` to get information about account token balances. We need to be able to support getting a new type of asset on mainnet and holesky chains, _Staked Ethereum_, which is not a token but represents the amount of ETH staked using our products. **What is the solution your changes offer and how does it work?** - We update the `AssetContractController` with a new method, `getStakedBalanceForChain`, which gets staked ethereum balances per account from the Stakewise vault contract. We update the AccountTrackerController with options to `includeStakedAssets` and to add a `getStakedBalanceForChain` method. - We bind `AssetContractController.getStakedBalanceForChain` to `getStakedBalanceForChain` option property in `metamask-mobile` code and then set `includeStakingAssets` option to the boolean feature flag for ETH Staking on Mobile. - We use the AccountTrackerController state in mobile to update the account `balance` and now, if enabled the `stakedBalance` as well. **Are there any changes whose purpose might not obvious to those unfamiliar with the domain?** - We don't want to show `stakedBalance` if not on a supported network, and so return undefined vs defaulting to zero hex. If there is an error and we are on a supported network, we want to default to zero hex. **If your primary goal was to update one package but you found you had to update another one along the way, why did you do so?** - There is 1 package affected **If you had to upgrade a dependency, why did you do so?** - No need to update dependency ## References **Are there any issues that this pull request is tied to?** - Relates to https://consensyssoftware.atlassian.net/browse/STAKE-817 **Are there other links that reviewers should consult to understand these changes better?** - https://github.com/MetaMask/metamask-mobile/pull/12146 PR to MM mobile with patched `assets-controllers@38.3.0` **Are there client or consumer pull requests to adopt any breaking changes?** - No ## Changelog ### `@metamask/assets-controllers` **ADDED**: AssetsContractController.getStakedBalanceForChain method to get staked ethereum balance for an address **ADDED**: AccountTrackerController options `includeStakedEthereum` and `getStakedBalanceForChain` for turning on staked balance functionality and providing a method to do so ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- .../src/AccountTrackerController.test.ts | 324 ++++++++++++++++++ .../src/AccountTrackerController.ts | 66 +++- .../src/AssetsContractController.test.ts | 136 ++++++++ .../src/AssetsContractController.ts | 84 ++++- ...tractControllerWithNetworkClientId.test.ts | 75 ++++ packages/assets-controllers/src/assetsUtil.ts | 12 + 6 files changed, 686 insertions(+), 11 deletions(-) diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index 4c475f0b75..bd27eecb87 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -248,6 +248,123 @@ describe('AccountTrackerController', () => { }, ); }); + + it('should update staked balance when includeStakedAssets is enabled', async () => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x10')) + .mockReturnValueOnce(Promise.resolve('0x11')); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: jest.fn().mockResolvedValue('0x1'), + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller }) => { + await controller.refresh(); + + expect(controller.state).toStrictEqual({ + accounts: { + [CHECKSUM_ADDRESS_1]: { balance: '0x10', stakedBalance: '0x1' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x10', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should not update staked balance when includeStakedAssets is disabled', async () => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x13')) + .mockReturnValueOnce(Promise.resolve('0x14')); + + await withController( + { + options: { + includeStakedAssets: false, + getStakedBalanceForChain: jest.fn().mockResolvedValue('0x1'), + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller }) => { + await controller.refresh(); + + expect(controller.state).toStrictEqual({ + accounts: { + [CHECKSUM_ADDRESS_1]: { balance: '0x13' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x13', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should update staked balance when includeStakedAssets and multi-account is enabled', async () => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x11')) + .mockReturnValueOnce(Promise.resolve('0x12')); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: jest.fn().mockResolvedValue('0x1'), + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller }) => { + await controller.refresh(); + + expect(controller.state).toStrictEqual({ + accounts: { + [CHECKSUM_ADDRESS_1]: { balance: '0x11', stakedBalance: '0x1' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x12', stakedBalance: '0x1' }, + }, + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x11', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x12', + stakedBalance: '0x1', + }, + }, + }, + }); + }, + ); + }); }); describe('with networkClientId', () => { @@ -438,6 +555,185 @@ describe('AccountTrackerController', () => { }, ); }); + + it('should update staked balance when includeStakedAssets is enabled', async () => { + const networkClientId = 'holesky'; + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x10')) + .mockReturnValueOnce(Promise.resolve('0x11')); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: jest.fn().mockResolvedValue('0x1'), + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x4268', + }), + }, + }, + async ({ controller }) => { + await controller.refresh(); + + expect(controller.state).toStrictEqual({ + accounts: { + [CHECKSUM_ADDRESS_1]: { balance: '0x10', stakedBalance: '0x1' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x10', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should not update staked balance when includeStakedAssets is disabled', async () => { + const networkClientId = 'holesky'; + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x13')) + .mockReturnValueOnce(Promise.resolve('0x14')); + + await withController( + { + options: { + includeStakedAssets: false, + getStakedBalanceForChain: jest.fn().mockResolvedValue('0x1'), + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x4268', + }), + }, + }, + async ({ controller }) => { + await controller.refresh(); + + expect(controller.state).toStrictEqual({ + accounts: { + [CHECKSUM_ADDRESS_1]: { balance: '0x13' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x13', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should update staked balance when includeStakedAssets and multi-account is enabled', async () => { + const networkClientId = 'holesky'; + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x11')) + .mockReturnValueOnce(Promise.resolve('0x12')); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: jest.fn().mockResolvedValue('0x1'), + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x4268', + }), + }, + }, + async ({ controller }) => { + await controller.refresh(); + + expect(controller.state).toStrictEqual({ + accounts: { + [CHECKSUM_ADDRESS_1]: { balance: '0x11', stakedBalance: '0x1' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x12', stakedBalance: '0x1' }, + }, + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x11', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x12', + stakedBalance: '0x1', + }, + }, + }, + }); + }, + ); + }); + + it('should not update staked balance when includeStakedAssets and multi-account is enabled if network unsupported', async () => { + const networkClientId = 'polygon'; + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x11')) + .mockReturnValueOnce(Promise.resolve('0x12')); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: jest.fn().mockResolvedValue(undefined), + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x89', + }), + }, + }, + async ({ controller }) => { + await controller.refresh(); + + expect(controller.state).toStrictEqual({ + accounts: { + [CHECKSUM_ADDRESS_1]: { balance: '0x11' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x12' }, + }, + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x11', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x12', + }, + }, + }, + }); + }, + ); + }); }); }); @@ -462,6 +758,33 @@ describe('AccountTrackerController', () => { }, ); }); + + it('should sync staked balance with addresses', async () => { + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: jest.fn().mockResolvedValue('0x1'), + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + }, + async ({ controller }) => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x10')) + .mockReturnValueOnce(Promise.resolve('0x20')); + const result = await controller.syncBalanceWithAddresses([ + ADDRESS_1, + ADDRESS_2, + ]); + expect(result[ADDRESS_1].balance).toBe('0x10'); + expect(result[ADDRESS_2].balance).toBe('0x20'); + expect(result[ADDRESS_1].stakedBalance).toBe('0x1'); + expect(result[ADDRESS_2].stakedBalance).toBe('0x1'); + }, + ); + }); }); it('should call refresh every interval on legacy polling', async () => { @@ -647,6 +970,7 @@ async function withController( const controller = new AccountTrackerController({ messenger: accountTrackerMessenger, + getStakedBalanceForChain: jest.fn(), ...options, }); diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index e58bac61fc..b6ad00396d 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -26,6 +26,11 @@ import { type Hex, assert } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { cloneDeep } from 'lodash'; +import type { + AssetsContractController, + StakedBalance, +} from './AssetsContractController'; + /** * The name of the {@link AccountTrackerController}. */ @@ -35,10 +40,12 @@ const controllerName = 'AccountTrackerController'; * @type AccountInformation * * Account information object - * @property balance - Hex string of an account balancec in wei + * @property balance - Hex string of an account balance in wei + * @property stakedBalance - Hex string of an account staked balance in wei */ export type AccountInformation = { balance: string; + stakedBalance?: string; }; /** @@ -135,6 +142,10 @@ export class AccountTrackerController extends StaticIntervalPollingController { readonly #refreshMutex = new Mutex(); + readonly #includeStakedAssets: boolean; + + readonly #getStakedBalanceForChain: AssetsContractController['getStakedBalanceForChain']; + #handle?: ReturnType; /** @@ -144,15 +155,21 @@ export class AccountTrackerController extends StaticIntervalPollingController; messenger: AccountTrackerControllerMessenger; + getStakedBalanceForChain: AssetsContractController['getStakedBalanceForChain']; + includeStakedAssets?: boolean; }) { const { selectedNetworkClientId } = messenger.call( 'NetworkController:getState', @@ -175,6 +192,10 @@ export class AccountTrackerController extends StaticIntervalPollingController { @@ -398,28 +431,41 @@ export class AccountTrackerController extends StaticIntervalPollingController> { + ): Promise< + Record + > { const { ethQuery } = this.#getCorrectNetworkClient(networkClientId); return await Promise.all( - addresses.map((address): Promise<[string, string] | undefined> => { - return safelyExecuteWithTimeout(async () => { - assert(ethQuery, 'Provider not set.'); - const balance = await query(ethQuery, 'getBalance', [address]); - return [address, balance]; - }); - }), + addresses.map( + (address): Promise<[string, string, StakedBalance] | undefined> => { + return safelyExecuteWithTimeout(async () => { + assert(ethQuery, 'Provider not set.'); + const balance = await query(ethQuery, 'getBalance', [address]); + + let stakedBalance: StakedBalance; + if (this.#includeStakedAssets) { + stakedBalance = await this.#getStakedBalanceForChain( + address, + networkClientId, + ); + } + return [address, balance, stakedBalance]; + }); + }, + ), ).then((value) => { return value.reduce((obj, item) => { if (!item) { return obj; } - const [address, balance] = item; + const [address, balance, stakedBalance] = item; return { ...obj, [address]: { balance, + stakedBalance, }, }; }, {}); diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index bcfdb6ba14..8df5743c3e 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -1274,4 +1274,140 @@ describe('AssetsContractController', () => { expect(uri.toLowerCase()).toStrictEqual(expectedUri); messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); + + it('should get the staked ethereum balance for an address', async () => { + const { assetsContract, messenger, provider, networkClientConfiguration } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + // getShares + { + request: { + method: 'eth_call', + params: [ + { + to: '0x4fef9d741011476750a243ac70b9789a63dd47df', + data: '0xf04da65b0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000de0b6b3a7640000', + }, + }, + // convertToAssets + { + request: { + method: 'eth_call', + params: [ + { + to: '0x4fef9d741011476750a243ac70b9789a63dd47df', + data: '0x07a2d13a0000000000000000000000000000000000000000000000000de0b6b3a7640000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000001bc16d674ec80000', + }, + }, + ], + }); + + const balance = await assetsContract.getStakedBalanceForChain( + TEST_ACCOUNT_PUBLIC_ADDRESS, + ); + + // exchange rate shares = 1e18 + // exchange rate share to assets = 2e18 + // user shares = 1e18 + // user assets = 2e18 + + expect(balance).toBeDefined(); + expect(balance).toBe('0x1bc16d674ec80000'); + expect(BigNumber.from(balance).toString()).toBe((2e18).toString()); + + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should return default of zero hex as staked ethereum balance if user has no shares', async () => { + const errorSpy = jest.spyOn(console, 'error'); + const { assetsContract, messenger, provider, networkClientConfiguration } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + // getShares + { + request: { + method: 'eth_call', + params: [ + { + to: '0x4fef9d741011476750a243ac70b9789a63dd47df', + data: '0xf04da65b0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }, + }, + ], + }); + + const balance = await assetsContract.getStakedBalanceForChain( + TEST_ACCOUNT_PUBLIC_ADDRESS, + ); + + expect(balance).toBeDefined(); + expect(balance).toBe('0x00'); + expect(BigNumber.from(balance).toString()).toBe('0'); + expect(errorSpy).toHaveBeenCalledTimes(0); + + errorSpy.mockRestore(); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should return default of zero hex as staked ethereum balance if there is any error thrown', async () => { + let error; + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce((e) => { + error = e; + }); + const { assetsContract, messenger, provider } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + const balance = await assetsContract.getStakedBalanceForChain( + TEST_ACCOUNT_PUBLIC_ADDRESS, + ); + + expect(balance).toBeDefined(); + expect(balance).toBe('0x00'); + expect(BigNumber.from(balance).toString()).toBe('0'); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith(error); + + errorSpy.mockRestore(); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should throw missing provider error when getting staked ethereum balance and missing provider', async () => { + const { assetsContract, messenger } = await setupAssetContractControllers(); + await expect( + assetsContract.getStakedBalanceForChain(TEST_ACCOUNT_PUBLIC_ADDRESS), + ).rejects.toThrow(MISSING_PROVIDER_ERROR); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); }); diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index 8601a5fc8f..323d90ead7 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -1,3 +1,5 @@ +// import { BigNumber } from '@ethersproject/bignumber'; +import { BigNumber } from '@ethersproject/bignumber'; import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { @@ -19,7 +21,10 @@ import { getKnownPropertyNames, type Hex } from '@metamask/utils'; import type BN from 'bn.js'; import abiSingleCallBalancesContract from 'single-call-balance-checker-abi'; -import { SupportedTokenDetectionNetworks } from './assetsUtil'; +import { + SupportedStakedBalanceNetworks, + SupportedTokenDetectionNetworks, +} from './assetsUtil'; import { ERC20Standard } from './Standards/ERC20Standard'; import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard'; import { ERC721Standard } from './Standards/NftStandards/ERC721/ERC721Standard'; @@ -69,6 +74,13 @@ export const SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID = { '0x6aa75276052d96696134252587894ef5ffa520af', } as const satisfies Record; +export const STAKING_CONTRACT_ADDRESS_BY_CHAINID = { + [SupportedStakedBalanceNetworks.mainnet]: + '0x4fef9d741011476750a243ac70b9789a63dd47df', + [SupportedStakedBalanceNetworks.holesky]: + '0x37bf0883c27365cffcd0c4202918df930989891f', +} as const satisfies Record; + export const MISSING_PROVIDER_ERROR = 'AssetsContractController failed to set the provider correctly. A provider must be set for this method to be available'; @@ -197,6 +209,8 @@ export type AssetsContractControllerMessenger = RestrictedControllerMessenger< AllowedEvents['type'] >; +export type StakedBalance = string | undefined; + /** * Controller that interacts with contracts on mainnet through web3 */ @@ -688,6 +702,74 @@ export class AssetsContractController { } return nonZeroBalances; } + + /** + * Get the staked ethereum balance for an address in a single call. + * + * @param address - The address to check staked ethereum balance for. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns The hex staked ethereum balance for address. + */ + async getStakedBalanceForChain( + address: string, + networkClientId?: NetworkClientId, + ): Promise { + const chainId = this.#getCorrectChainId(networkClientId); + const provider = this.#getCorrectProvider(networkClientId); + + // balance defaults to zero + let balance: BigNumber = BigNumber.from(0); + + // Only fetch staked balance on supported networks + if ( + ![ + SupportedStakedBalanceNetworks.mainnet, + SupportedStakedBalanceNetworks.holesky, + ].includes(chainId as SupportedStakedBalanceNetworks) + ) { + return undefined as StakedBalance; + } + // Only fetch staked balance if contract address exists + if ( + !((id): id is keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID => + id in STAKING_CONTRACT_ADDRESS_BY_CHAINID)(chainId) + ) { + return undefined as StakedBalance; + } + + const contractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; + const abi = [ + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'getShares', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'convertToAssets', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + ]; + + try { + const contract = new Contract(contractAddress, abi, provider); + const userShares = await contract.getShares(address); + + // convert shares to assets only if address shares > 0 else return default balance + if (!userShares.lte(0)) { + balance = await contract.convertToAssets(userShares.toString()); + } + } catch (error) { + // if we get an error, log and return the default value + console.error(error); + } + + return balance.toHexString(); + } } export default AssetsContractController; diff --git a/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts b/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts index 97760db63f..c8395a0d64 100644 --- a/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts +++ b/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts @@ -1,3 +1,4 @@ +import { BigNumber } from '@ethersproject/bignumber'; import { BUILT_IN_NETWORKS } from '@metamask/controller-utils'; import { NetworkClientType } from '@metamask/network-controller'; @@ -900,4 +901,78 @@ describe('AssetsContractController with NetworkClientId', () => { expect(uri.toLowerCase()).toStrictEqual(expectedUri); messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); + + it('should get the staked ethereum balance for an address', async () => { + const { assetsContract, messenger, provider, networkClientConfiguration } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + // getShares + { + request: { + method: 'eth_call', + params: [ + { + to: '0x4fef9d741011476750a243ac70b9789a63dd47df', + data: '0xf04da65b0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000de0b6b3a7640000', + }, + }, + // convertToAssets + { + request: { + method: 'eth_call', + params: [ + { + to: '0x4fef9d741011476750a243ac70b9789a63dd47df', + data: '0x07a2d13a0000000000000000000000000000000000000000000000000de0b6b3a7640000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000001bc16d674ec80000', + }, + }, + ], + }); + + const balance = await assetsContract.getStakedBalanceForChain( + TEST_ACCOUNT_PUBLIC_ADDRESS, + 'mainnet', + ); + + // exchange rate shares = 1e18 + // exchange rate share to assets = 2e18 + // user shares = 1e18 + // user assets = 2e18 + + expect(balance).toBeDefined(); + expect(balance).toBe('0x1bc16d674ec80000'); + expect(BigNumber.from(balance).toString()).toBe((2e18).toString()); + + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should default staked ethereum balance to undefined if network is not supported', async () => { + const { assetsContract, provider } = await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + const balance = await assetsContract.getStakedBalanceForChain( + TEST_ACCOUNT_PUBLIC_ADDRESS, + 'sepolia', + ); + + expect(balance).toBeUndefined(); + }); }); diff --git a/packages/assets-controllers/src/assetsUtil.ts b/packages/assets-controllers/src/assetsUtil.ts index a7d24d9592..5392a2419d 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -187,6 +187,18 @@ export enum SupportedTokenDetectionNetworks { moonriver = '0x505', // decimal: 1285 } +/** + * Networks where staked balance is supported - Values are in hex format + */ +export enum SupportedStakedBalanceNetworks { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention + mainnet = '0x1', // decimal: 1 + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention + holesky = '0x4268', // decimal: 17000 +} + /** * Check if token detection is enabled for certain networks. *