From 98418899add103b414bff050c44292ab0c4cb412 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Mon, 28 Oct 2024 13:10:20 -0500 Subject: [PATCH 1/6] feat: add ability to include Staked ETH balances in AccountTrackerController - add SupportedStakedBalanceNetworks enum to assetsUtils.ts - add AssetsContractController->getStakedBalanceForChain to support stakewise contract balance calls - add ability to includeStakedAssets in AccountTrackerController class options which returns staked balances along with native balance on refresh/sync --- .../src/AccountTrackerController.ts | 61 +++++++++++-- .../src/AssetsContractController.ts | 88 ++++++++++++++++++- packages/assets-controllers/src/assetsUtil.ts | 12 +++ 3 files changed, 151 insertions(+), 10 deletions(-) diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index e58bac61fc..e2762c3dba 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -26,6 +26,8 @@ import { type Hex, assert } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { cloneDeep } from 'lodash'; +import type { AssetsContractController } from './AssetsContractController'; + /** * The name of the {@link AccountTrackerController}. */ @@ -35,10 +37,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 +139,10 @@ export class AccountTrackerController extends StaticIntervalPollingController { readonly #refreshMutex = new Mutex(); + readonly #includeStakedAssets: boolean; + + readonly #getStakedBalanceForChain: AssetsContractController['getStakedBalanceForChain']; + #handle?: ReturnType; /** @@ -144,15 +152,21 @@ export class AccountTrackerController extends StaticIntervalPollingController; messenger: AccountTrackerControllerMessenger; + getStakedBalanceForChain: AssetsContractController['getStakedBalanceForChain']; + includeStakedAssets?: boolean; }) { const { selectedNetworkClientId } = messenger.call( 'NetworkController:getState', @@ -175,6 +189,10 @@ export class AccountTrackerController extends StaticIntervalPollingController { @@ -402,24 +432,37 @@ export class AccountTrackerController extends StaticIntervalPollingController => { - return safelyExecuteWithTimeout(async () => { - assert(ethQuery, 'Provider not set.'); - const balance = await query(ethQuery, 'getBalance', [address]); - return [address, balance]; - }); - }), + addresses.map( + ( + address, + ): Promise<[string, string, string | undefined] | undefined> => { + return safelyExecuteWithTimeout(async () => { + assert(ethQuery, 'Provider not set.'); + const balance = await query(ethQuery, 'getBalance', [address]); + + let stakedBalance: string | undefined; + 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.ts b/packages/assets-controllers/src/AssetsContractController.ts index 8601a5fc8f..be45ce3401 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,78 @@ export class AssetsContractController { } return nonZeroBalances; } + + async getStakedBalanceForChain( + address: string, + networkClientId?: NetworkClientId, + ): Promise { + const chainId = this.getChainId(networkClientId); + const provider = this.getProvider(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 exchangeRateDenominator = BigNumber.from((1e18).toString()); + const multiplier = exchangeRateDenominator; + const userShares = await contract.getShares(address); + + // convert shares to assets only if address shares > 0 else return default balance + if (!userShares.lte(0)) { + const exchangeRateNumerator = await contract.convertToAssets( + exchangeRateDenominator, + ); + const exchangeRate = exchangeRateNumerator + .mul(multiplier) + .div(exchangeRateDenominator); + + const userAssets = userShares.mul(exchangeRate).div(multiplier); + + balance = userAssets; + } + } 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/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. * From a459bdd362526fbc71f4e12575a6c2ba7014e48e Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 30 Oct 2024 17:42:02 -0500 Subject: [PATCH 2/6] chore: fix unit tests --- packages/assets-controllers/src/AccountTrackerController.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index 4c475f0b75..bcd7f9f55c 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -648,6 +648,7 @@ async function withController( const controller = new AccountTrackerController({ messenger: accountTrackerMessenger, ...options, + getStakedBalanceForChain: jest.fn(), }); return await testFunction({ From cb9a55ce8f04f9b8cf1f34e069dc0365c1ba07bb Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 30 Oct 2024 23:22:31 -0500 Subject: [PATCH 3/6] chore: add unit tests and clean up --- .../src/AccountTrackerController.test.ts | 325 +++++++++++++++++- .../src/AccountTrackerController.ts | 15 +- .../src/AssetsContractController.test.ts | 136 ++++++++ ...tractControllerWithNetworkClientId.test.ts | 75 ++++ 4 files changed, 544 insertions(+), 7 deletions(-) diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index bcd7f9f55c..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,8 +970,8 @@ async function withController( const controller = new AccountTrackerController({ messenger: accountTrackerMessenger, - ...options, getStakedBalanceForChain: jest.fn(), + ...options, }); return await testFunction({ diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index e2762c3dba..b6ad00396d 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -26,7 +26,10 @@ import { type Hex, assert } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { cloneDeep } from 'lodash'; -import type { AssetsContractController } from './AssetsContractController'; +import type { + AssetsContractController, + StakedBalance, +} from './AssetsContractController'; /** * The name of the {@link AccountTrackerController}. @@ -428,19 +431,19 @@ export class AccountTrackerController extends StaticIntervalPollingController> { + ): Promise< + Record + > { const { ethQuery } = this.#getCorrectNetworkClient(networkClientId); return await Promise.all( addresses.map( - ( - address, - ): Promise<[string, string, string | undefined] | undefined> => { + (address): Promise<[string, string, StakedBalance] | undefined> => { return safelyExecuteWithTimeout(async () => { assert(ethQuery, 'Provider not set.'); const balance = await query(ethQuery, 'getBalance', [address]); - let stakedBalance: string | undefined; + let stakedBalance: StakedBalance; if (this.#includeStakedAssets) { stakedBalance = await this.#getStakedBalanceForChain( address, diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index bcfdb6ba14..73ab03a4a1 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.configure({ 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.configure({ 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.configure({ 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/AssetsContractControllerWithNetworkClientId.test.ts b/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts index 97760db63f..4b40dccb99 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.configure({ 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.configure({ provider }); + + const balance = await assetsContract.getStakedBalanceForChain( + TEST_ACCOUNT_PUBLIC_ADDRESS, + 'sepolia', + ); + + expect(balance).toBeUndefined(); + }); }); From bda919b645a1db1ba7f8932f31debb48586729b0 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 31 Oct 2024 00:49:58 -0500 Subject: [PATCH 4/6] chore: merge 41.0.0 and update for version --- .../assets-controllers/src/AssetsContractController.test.ts | 6 +++--- packages/assets-controllers/src/AssetsContractController.ts | 4 ++-- .../src/AssetsContractControllerWithNetworkClientId.test.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index 73ab03a4a1..8df5743c3e 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -1278,7 +1278,7 @@ describe('AssetsContractController', () => { it('should get the staked ethereum balance for an address', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -1340,7 +1340,7 @@ describe('AssetsContractController', () => { const errorSpy = jest.spyOn(console, 'error'); const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -1387,7 +1387,7 @@ describe('AssetsContractController', () => { }); const { assetsContract, messenger, provider } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); const balance = await assetsContract.getStakedBalanceForChain( TEST_ACCOUNT_PUBLIC_ADDRESS, diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index be45ce3401..ed2532c120 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -707,8 +707,8 @@ export class AssetsContractController { address: string, networkClientId?: NetworkClientId, ): Promise { - const chainId = this.getChainId(networkClientId); - const provider = this.getProvider(networkClientId); + const chainId = this.#getCorrectChainId(networkClientId); + const provider = this.#getCorrectProvider(networkClientId); // balance defaults to zero let balance: BigNumber = BigNumber.from(0); diff --git a/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts b/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts index 4b40dccb99..c8395a0d64 100644 --- a/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts +++ b/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts @@ -905,7 +905,7 @@ describe('AssetsContractController with NetworkClientId', () => { it('should get the staked ethereum balance for an address', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -966,7 +966,7 @@ describe('AssetsContractController with NetworkClientId', () => { it('should default staked ethereum balance to undefined if network is not supported', async () => { const { assetsContract, provider } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); const balance = await assetsContract.getStakedBalanceForChain( TEST_ACCOUNT_PUBLIC_ADDRESS, From f3a3c2e833627c2454eb42f6f0a1c0972c21b6db Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 31 Oct 2024 12:35:41 -0500 Subject: [PATCH 5/6] chore(feedback): remove exchange rate calc not needed --- .../src/AssetsContractController.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index ed2532c120..082219a8cd 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -750,22 +750,11 @@ export class AssetsContractController { try { const contract = new Contract(contractAddress, abi, provider); - const exchangeRateDenominator = BigNumber.from((1e18).toString()); - const multiplier = exchangeRateDenominator; const userShares = await contract.getShares(address); // convert shares to assets only if address shares > 0 else return default balance if (!userShares.lte(0)) { - const exchangeRateNumerator = await contract.convertToAssets( - exchangeRateDenominator, - ); - const exchangeRate = exchangeRateNumerator - .mul(multiplier) - .div(exchangeRateDenominator); - - const userAssets = userShares.mul(exchangeRate).div(multiplier); - - balance = userAssets; + balance = await contract.convertToAssets(userShares.toString()); } } catch (error) { // if we get an error, log and return the default value From 7eeffaec1381caea53d86e01e592ec8410572ef8 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Fri, 1 Nov 2024 16:58:03 -0500 Subject: [PATCH 6/6] chore: add jsdoc for getStakedBalanceForChain --- .../assets-controllers/src/AssetsContractController.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index 082219a8cd..323d90ead7 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -703,6 +703,13 @@ 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,