diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 49450a4b687..13e48bc3e85 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -929,22 +929,19 @@ } }, "packages/earn-controller/src/EarnController.test.ts": { - "@typescript-eslint/explicit-function-return-type": { + "no-restricted-syntax": { "count": 1 - }, - "jest/unbound-method": { - "count": 36 } }, "packages/earn-controller/src/EarnController.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 9 - }, - "id-length": { - "count": 2 + "count": 4 }, "no-negated-condition": { "count": 3 + }, + "no-restricted-syntax": { + "count": 1 } }, "packages/earn-controller/src/selectors.ts": { diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index dd9a4f037c6..a10a7528afd 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -9,10 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `EarnController` constructor no longer accepts `selectedNetworkClientId` and no longer performs async work during construction. Consumers must call `init()` after construction. The messenger must now allow `AccountTreeController:stateChange` and `NetworkController:getState` ([#8421](https://github.com/MetaMask/core/pull/8421)) +- **BREAKING:** `refreshPooledStakingData` and `refreshLendingData` no longer call eligibility checks internally. Eligibility is fetched once during `init()`. Consumers that relied on these methods to keep eligibility state current must call `refreshEarnEligibility` separately ([#8421](https://github.com/MetaMask/core/pull/8421)) - Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) - Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.1.0` ([#8432](https://github.com/MetaMask/core/pull/8432)) +### Removed + +- **BREAKING:** Removed `EarnController:refreshLendingEligibility` messenger action and `EarnControllerRefreshLendingEligibilityAction` type. Use `EarnController:refreshEarnEligibility` instead ([#8421](https://github.com/MetaMask/core/pull/8421)) + ## [11.2.1] ### Changed diff --git a/packages/earn-controller/src/EarnController-method-action-types.ts b/packages/earn-controller/src/EarnController-method-action-types.ts index 76e3cb44ce9..6171c7c4887 100644 --- a/packages/earn-controller/src/EarnController-method-action-types.ts +++ b/packages/earn-controller/src/EarnController-method-action-types.ts @@ -116,19 +116,6 @@ export type EarnControllerRefreshLendingPositionsAction = { handler: EarnController['refreshLendingPositions']; }; -/** - * Refreshes the lending eligibility status for the current account. - * Updates the eligibility status in the controller state based on the location and address blocklist for compliance. - * - * @param options - Optional arguments - * @param [options.address] - The address to refresh lending eligibility for (optional). - * @returns A promise that resolves when the eligibility status has been updated - */ -export type EarnControllerRefreshLendingEligibilityAction = { - type: `EarnController:refreshLendingEligibility`; - handler: EarnController['refreshLendingEligibility']; -}; - /** * Refreshes all lending related data including markets, positions, and eligibility. * This method allows partial success, meaning some data may update while other requests fail. @@ -302,7 +289,6 @@ export type EarnControllerMethodActions = | EarnControllerRefreshPooledStakingDataAction | EarnControllerRefreshLendingMarketsAction | EarnControllerRefreshLendingPositionsAction - | EarnControllerRefreshLendingEligibilityAction | EarnControllerRefreshLendingDataAction | EarnControllerRefreshTronStakingApyAction | EarnControllerGetTronStakingApyAction diff --git a/packages/earn-controller/src/EarnController.test.ts b/packages/earn-controller/src/EarnController.test.ts index 649bc6dea4d..85aef3229b7 100644 --- a/packages/earn-controller/src/EarnController.test.ts +++ b/packages/earn-controller/src/EarnController.test.ts @@ -125,12 +125,14 @@ function getEarnControllerMessenger( rootMessenger.delegate({ messenger: earnControllerMessenger, actions: [ + 'NetworkController:getState', 'NetworkController:getNetworkClientById', 'AccountTreeController:getAccountsFromSelectedAccountGroup', ], events: [ 'NetworkController:networkDidChange', 'AccountTreeController:selectedAccountGroupChange', + 'AccountTreeController:stateChange', 'TransactionController:transactionConfirmed', ], }); @@ -682,22 +684,29 @@ const setupController = async ({ }, })), + mockGetNetworkControllerState = jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '1', + })), + mockGetAccountsFromSelectedAccountGroup = jest.fn(() => [ mockInternalAccount1, ]), addTransactionFn = jest.fn(), - selectedNetworkClientId = '1', }: { options?: Partial[0]>; mockGetNetworkClientById?: jest.Mock; mockGetNetworkControllerState?: jest.Mock; mockGetAccountsFromSelectedAccountGroup?: jest.Mock; addTransactionFn?: jest.Mock; - selectedNetworkClientId?: string; -} = {}) => { +} = {}): Promise<{ controller: EarnController; messenger: RootMessenger }> => { const messenger = buildMessenger(); + messenger.registerActionHandler( + 'NetworkController:getState', + mockGetNetworkControllerState, + ); messenger.registerActionHandler( 'NetworkController:getNetworkClientById', mockGetNetworkClientById, @@ -713,20 +722,24 @@ const setupController = async ({ messenger: earnControllerMessenger, ...options, addTransactionFn, - selectedNetworkClientId, }); - // We create a promise here and wait for it to resolve. - // We do this to try and ensure that the controller is fully initialized before we start testing. - // This is a hack; really we should implement an async 'init' method on the controller which does required async setup - // rather than having async calls in the constructor which is an anti-pattern. + await controller.init(); + + // Wait for fire-and-forget async operations started by init() to settle. await new Promise((resolve) => setTimeout(resolve, 0)); return { controller, messenger }; }; const EarnApiServiceMock = jest.mocked(EarnApiService); -let mockedEarnApiService: Partial; + +type MockedEarnApiService = { + pooledStaking?: Partial>; + lending?: Partial>; +}; + +let mockedEarnApiService: MockedEarnApiService; const isSupportedLendingChainMock = jest.requireMock( '@metamask/stake-sdk', @@ -741,7 +754,7 @@ describe('EarnController', () => { isSupportedLendingChainMock.mockReturnValue(true); isSupportedPooledStakingChainMock.mockReturnValue(true); - // Apply EarnSdk mock before initializing EarnController + // Apply EarnSdk mock before initializing EarnController` (EarnSdk.create as jest.Mock).mockImplementation(() => ({ contracts: { pooledStaking: null, @@ -766,7 +779,7 @@ describe('EarnController', () => { .fn() .mockResolvedValue(mockPooledStakingVaultApyAverages), getUserDailyRewards: jest.fn().mockResolvedValue(mockUserDailyRewards), - } as Partial, + } as Partial>, lending: { getMarkets: jest.fn().mockResolvedValue(mockLendingMarkets), getPositions: jest.fn().mockResolvedValue(mockLendingPositions), @@ -776,8 +789,8 @@ describe('EarnController', () => { getHistoricMarketApys: jest .fn() .mockResolvedValue(mockLendingHistoricMarketApys), - } as Partial, - } as Partial; + } as Partial>, + }; EarnApiServiceMock.mockImplementation( () => mockedEarnApiService as EarnApiService, @@ -848,6 +861,287 @@ describe('EarnController', () => { }); }); + describe('init', () => { + it('does not re-run initialization when called again after init has already completed', async () => { + const { controller } = await setupController(); + + // init() was already called once inside setupController; call it again after it settled. + await controller.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // EarnSdk.create and data-fetch calls should not have increased beyond + // the single init() call made during setupController. + expect(EarnSdk.create).toHaveBeenCalledTimes(1); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalledTimes(2); // 2 chains (ETH + HOODI) from the first init() + }); + + it('does not re-run initialization when called concurrently before init has completed', async () => { + // Build the controller without calling init() so we can control the race ourselves. + // Reuse the same mock factories that setupController defaults to. + const rootMessenger = buildMessenger(); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '1', + })), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn(() => ({ + configuration: { chainId: toHex(1) }, + provider: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + })) as unknown as jest.Mock, + ); + rootMessenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + jest.fn(() => [mockInternalAccount1]), + ); + + const earnControllerMessenger = getEarnControllerMessenger(rootMessenger); + const controller = new EarnController({ + messenger: earnControllerMessenger, + addTransactionFn: jest.fn(), + }); + + // Fire two concurrent init() calls — neither has settled yet. + await Promise.all([controller.init(), controller.init()]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // SDK should only have been created once despite two concurrent calls. + expect(EarnSdk.create).toHaveBeenCalledTimes(1); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalledTimes(2); // 2 chains (ETH + HOODI), not doubled to 4 + }); + + it('allows retry when init fails', async () => { + const rootMessenger = buildMessenger(); + + // First call to NetworkController:getState throws, second succeeds. + const mockGetState = jest + .fn() + .mockImplementationOnce(() => { + throw new Error('NetworkController not ready'); + }) + .mockReturnValue({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '1', + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + mockGetState, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn(() => ({ + configuration: { chainId: toHex(1) }, + provider: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + })) as unknown as jest.Mock, + ); + rootMessenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + jest.fn(() => [mockInternalAccount1]), + ); + + const earnControllerMessenger = getEarnControllerMessenger(rootMessenger); + const controller = new EarnController({ + messenger: earnControllerMessenger, + addTransactionFn: jest.fn(), + }); + + // First init() should reject and clear #initPromise. + await expect(controller.init()).rejects.toThrow( + 'NetworkController not ready', + ); + + // Second init() should succeed and trigger data fetches. + await controller.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(EarnSdk.create).toHaveBeenCalledTimes(1); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalledTimes(2); // 2 chains (ETH + HOODI) + }); + + describe('when no EVM account is available at init time', () => { + // Minimal AccountTreeControllerState shape used to trigger the stateChange event + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockAccountTreeStateWithGroup: any = { + selectedAccountGroup: 'keyring:test/0', + accountTree: { wallets: {} }, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }; + + it('defers portfolio refresh until AccountTreeController:stateChange fires with a non-empty selectedAccountGroup', async () => { + const mockGetAccounts = jest + .fn() + .mockReturnValueOnce([]) // No account during init + .mockReturnValue([mockInternalAccount1]); // Account available after stateChange + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // No eligibility or staking refresh should have happened during init + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).not.toHaveBeenCalled(); + + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenCalledWith([mockAccount1Address]); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalled(); + }); + + it('does not trigger portfolio refresh when selectedAccountGroup is empty', async () => { + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + messenger.publish( + 'AccountTreeController:stateChange', + { + ...mockAccountTreeStateWithGroup, + selectedAccountGroup: '', + }, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + }); + + it('does not trigger portfolio refresh when selectedAccountGroup only contains non-EVM accounts', async () => { + // Always returns no accounts, simulating a non-EVM-only group (e.g. Bitcoin-only) + const mockGetAccounts = jest.fn(() => []); + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // Publish with a non-empty selectedAccountGroup but no EVM account resolvable + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).not.toHaveBeenCalled(); + }); + + it('stays subscribed and fires when an EVM account eventually appears after a non-EVM-only state change', async () => { + const mockGetAccounts = jest + .fn() + .mockReturnValueOnce([]) // No account during init + .mockReturnValueOnce([]) // Still no EVM account on first stateChange (non-EVM group) + .mockReturnValue([mockInternalAccount1]); // EVM account available on second stateChange + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // First publish: group is non-empty but still no EVM account — should not refresh + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + + // Second publish: EVM account is now available — deferred refresh should fire + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenCalledWith([mockAccount1Address]); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalled(); + }); + + it('unsubscribes after the first non-empty selectedAccountGroup event', async () => { + const mockGetAccounts = jest + .fn() + .mockReturnValueOnce([]) // No account during init + .mockReturnValue([mockInternalAccount1]); // Account available after stateChange + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // First publish triggers the deferred refresh + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const eligibilityCallCount = + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility?.mock + .calls.length ?? 0; + + // Second publish should be ignored – handler was already unsubscribed + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenCalledTimes(eligibilityCallCount); + }); + }); + }); + describe('SDK initialization', () => { it('initializes SDK with correct chain ID on construction', async () => { await setupController(); @@ -1018,7 +1312,7 @@ describe('EarnController', () => { getVaultApyAverages: jest.fn().mockImplementation(() => { throw new Error('API Error getVaultApyAverages'); }), - } as unknown as PooledStakingApiService, + } as Partial>, }; EarnApiServiceMock.mockImplementation( @@ -1028,7 +1322,7 @@ describe('EarnController', () => { const { controller } = await setupController(); await expect(controller.refreshPooledStakingData()).rejects.toThrow( - 'Failed to refresh some staking data: API Error getPooledStakingEligibility, API Error getPooledStakes, API Error getVaultData, API Error getVaultDailyApys, API Error getVaultApyAverages, API Error getPooledStakes, API Error getVaultData, API Error getVaultDailyApys, API Error getVaultApyAverages', + 'Failed to refresh some staking data: API Error getPooledStakes, API Error getVaultData, API Error getVaultDailyApys, API Error getVaultApyAverages, API Error getPooledStakes, API Error getVaultData, API Error getVaultDailyApys, API Error getVaultApyAverages', ); expect(consoleErrorSpy).toHaveBeenCalled(); consoleErrorSpy.mockRestore(); @@ -1200,7 +1494,19 @@ describe('EarnController', () => { // Assertion on second call since the first is part of controller setup. expect( mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, - ).toHaveBeenNthCalledWith(3, [mockAccount2Address]); + ).toHaveBeenNthCalledWith(2, [mockAccount2Address]); + }); + + it('returns early without fetching when no address is available', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + await controller.refreshEarnEligibility(); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); }); }); @@ -1601,31 +1907,6 @@ describe('EarnController', () => { }); describe('Lending', () => { - describe('refreshLendingEligibility', () => { - it('fetches lending eligibility using active account (default)', async () => { - const { controller } = await setupController(); - - await controller.refreshLendingEligibility(); - - // Assertion on third call since the first and second calls are part of controller setup. - expect( - mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, - ).toHaveBeenNthCalledWith(3, [mockAccount1Address]); - }); - - it('fetches lending eligibility using options.address override', async () => { - const { controller } = await setupController(); - await controller.refreshLendingEligibility({ - address: mockAccount2Address, - }); - - // Assertion on third call since the first and second calls are part of controller setup. - expect( - mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, - ).toHaveBeenNthCalledWith(3, [mockAccount2Address]); - }); - }); - describe('refreshLendingPositions', () => { it('fetches using active account (default)', async () => { const { controller } = await setupController(); @@ -1648,6 +1929,18 @@ describe('EarnController', () => { mockedEarnApiService?.lending?.getPositions, ).toHaveBeenNthCalledWith(2, mockAccount2Address); }); + + it('returns early without fetching when no address is available', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + await controller.refreshLendingPositions(); + + expect( + mockedEarnApiService?.lending?.getPositions, + ).not.toHaveBeenCalled(); + }); }); describe('refreshLendingMarkets', () => { @@ -1674,9 +1967,6 @@ describe('EarnController', () => { expect( mockedEarnApiService?.lending?.getPositions, ).toHaveBeenCalledTimes(2); - expect( - mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, - ).toHaveBeenCalledTimes(3); // Additionally called once in controller setup by refreshPooledStakingData }); }); @@ -1995,7 +2285,10 @@ describe('EarnController', () => { })); const { controller } = await setupController({ - selectedNetworkClientId: '', + mockGetNetworkControllerState: jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '', + })), }); await expect( @@ -2186,7 +2479,10 @@ describe('EarnController', () => { })); const { controller } = await setupController({ - selectedNetworkClientId: '', + mockGetNetworkControllerState: jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '', + })), }); await expect( @@ -2377,7 +2673,10 @@ describe('EarnController', () => { })); const { controller } = await setupController({ - selectedNetworkClientId: '', + mockGetNetworkControllerState: jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '', + })), }); await expect( diff --git a/packages/earn-controller/src/EarnController.ts b/packages/earn-controller/src/EarnController.ts index 7fab7ef431a..dc33bdf343e 100644 --- a/packages/earn-controller/src/EarnController.ts +++ b/packages/earn-controller/src/EarnController.ts @@ -1,7 +1,9 @@ +import type { BigNumber } from '@ethersproject/bignumber'; import { Web3Provider } from '@ethersproject/providers'; import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, + AccountTreeControllerStateChangeEvent, } from '@metamask/account-tree-controller'; import type { ControllerGetStateAction, @@ -15,6 +17,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, NetworkControllerNetworkDidChangeEvent, NetworkState, } from '@metamask/network-controller'; @@ -47,7 +50,6 @@ import type { import type { EarnControllerMethodActions } from './EarnController-method-action-types'; import type { RefreshEarnEligibilityOptions, - RefreshLendingEligibilityOptions, RefreshLendingPositionsOptions, RefreshPooledStakesOptions, RefreshPooledStakingDataOptions, @@ -262,7 +264,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'refreshPooledStakingData', 'refreshLendingMarkets', 'refreshLendingPositions', - 'refreshLendingEligibility', 'refreshLendingData', 'refreshTronStakingApy', 'getTronStakingApy', @@ -295,6 +296,7 @@ export type EarnControllerActions = * All actions that EarnController calls internally. */ export type AllowedActions = + | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction; @@ -315,6 +317,7 @@ export type EarnControllerEvents = EarnControllerStateChangeEvent; * All events that EarnController subscribes to internally. */ export type AllowedEvents = + | AccountTreeControllerStateChangeEvent | AccountTreeControllerSelectedAccountGroupChangeEvent | TransactionControllerTransactionConfirmedEvent | NetworkControllerNetworkDidChangeEvent; @@ -341,7 +344,7 @@ export class EarnController extends BaseController< > { #earnSDK: EarnSdk | null = null; - #selectedNetworkClientId: string; + #initPromise: Promise | null = null; readonly #earnApiService: EarnApiService; @@ -355,13 +358,11 @@ export class EarnController extends BaseController< messenger, state = {}, addTransactionFn, - selectedNetworkClientId, env = EarnEnvironments.PROD, }: { messenger: EarnControllerMessenger; state?: Partial; addTransactionFn: typeof TransactionController.prototype.addTransaction; - selectedNetworkClientId: string; env?: EarnEnvironments; }) { super({ @@ -385,20 +386,13 @@ export class EarnController extends BaseController< this.#addTransactionFn = addTransactionFn; - this.#selectedNetworkClientId = selectedNetworkClientId; - - this.#initializeSDK(selectedNetworkClientId).catch(console.error); - this.refreshPooledStakingData().catch(console.error); - this.refreshLendingData().catch(console.error); - // Listen for network changes this.messenger.subscribe( 'NetworkController:networkDidChange', (networkControllerState: NetworkState) => { - this.#selectedNetworkClientId = - networkControllerState.selectedNetworkClientId; - - this.#initializeSDK(this.#selectedNetworkClientId).catch(console.error); + this.#initializeSDK( + networkControllerState.selectedNetworkClientId, + ).catch(console.error); // refresh pooled staking data this.refreshPooledStakingVaultMetadata().catch(console.error); @@ -466,12 +460,41 @@ export class EarnController extends BaseController< ); } + #refreshEarnPortfolio(address: string): void { + this.refreshEarnEligibility({ address }).catch(console.error); + this.refreshPooledStakingData({ address }).catch(console.error); + this.refreshLendingData().catch(console.error); + } + + async init(): Promise { + if (this.#initPromise) { + return this.#initPromise; + } + + this.#initPromise = (async (): Promise => { + await this.#initializeSDK(this.#getSelectedNetworkClientId()); + + const address = this.#getSelectedEvmAccountAddress(); + if (address) { + this.#refreshEarnPortfolio(address); + } else { + // Account tree state is not yet available, so we defer the refresh to when it is. + this.#refreshEarnPortfolioOnAccountReady(); + } + })().catch((error) => { + this.#initPromise = null; + throw error; + }); + + return this.#initPromise; + } + /** * Initializes the Earn SDK. * * @param networkClientId - The network client id to initialize the Earn SDK for. */ - async #initializeSDK(networkClientId: string) { + async #initializeSDK(networkClientId: string): Promise { const networkClient = this.messenger.call( 'NetworkController:getNetworkClientById', networkClientId, @@ -507,6 +530,16 @@ export class EarnController extends BaseController< } } + /** + * Gets the selected network client ID from NetworkController's live state. + * + * @returns The selected network client ID. + */ + #getSelectedNetworkClientId(): string { + return this.messenger.call('NetworkController:getState') + .selectedNetworkClientId; + } + /** * Gets the EVM account from the selected account group. * @@ -527,6 +560,36 @@ export class EarnController extends BaseController< return this.#getSelectedEvmAccount()?.address; } + /** + * Sets up a one-time subscription to AccountTreeController:stateChange that + * triggers address-dependent refreshes once both the selected account group + * is populated and an EVM account address is resolvable. Unsubscribes + * only after a refresh is triggered. + * + * This handles the case where EarnController.init() runs before + * AccountTreeController.init() has populated the selected account group. + */ + #refreshEarnPortfolioOnAccountReady(): void { + const handler = ({ + selectedAccountGroup, + }: { + selectedAccountGroup: string; + }): void => { + if (!selectedAccountGroup) { + return; + } + + const address = this.#getSelectedEvmAccountAddress(); + if (!address) { + return; + } + + this.messenger.unsubscribe('AccountTreeController:stateChange', handler); + this.#refreshEarnPortfolio(address); + }; + this.messenger.subscribe('AccountTreeController:stateChange', handler); + } + /** * Refreshes the pooled stakes data for the current account. * Fetches updated stake information including lifetime rewards, assets, and exit requests @@ -677,7 +740,7 @@ export class EarnController extends BaseController< */ async refreshPooledStakingVaultApyAverages( chainId: number = ChainId.ETHEREUM, - ) { + ): Promise { const chainIdToUse = isSupportedPooledStakingChain(chainId) ? chainId : ChainId.ETHEREUM; @@ -715,11 +778,6 @@ export class EarnController extends BaseController< }: RefreshPooledStakingDataOptions = {}): Promise { const errors: Error[] = []; - // Refresh earn eligibility once since it's not chain-specific - await this.refreshEarnEligibility({ address }).catch((error) => { - errors.push(error); - }); - for (const chainId of this.#supportedPooledStakingChains) { await Promise.all([ this.refreshPooledStakes({ resetCache, address, chainId }).catch( @@ -742,7 +800,7 @@ export class EarnController extends BaseController< if (errors.length > 0) { throw new Error( `Failed to refresh some staking data: ${errors - .map((e) => e.message) + .map((error) => error.message) .join(', ')}`, ); } @@ -795,38 +853,6 @@ export class EarnController extends BaseController< }); } - /** - * Refreshes the lending eligibility status for the current account. - * Updates the eligibility status in the controller state based on the location and address blocklist for compliance. - * - * @param options - Optional arguments - * @param [options.address] - The address to refresh lending eligibility for (optional). - * @returns A promise that resolves when the eligibility status has been updated - */ - async refreshLendingEligibility({ - address, - }: RefreshLendingEligibilityOptions = {}): Promise { - const addressToUse = address ?? this.#getSelectedEvmAccountAddress(); - // TODO: this is a temporary solution to refresh lending eligibility as - // the eligibility check is not yet implemented for lending - // this check will check the address against the same blocklist as the - // staking eligibility check - - if (!addressToUse) { - return; - } - - const { eligible: isEligible } = - await this.#earnApiService.pooledStaking.getPooledStakingEligibility([ - addressToUse, - ]); - - this.update((state) => { - state.lending.isEligible = isEligible; - state.pooled_staking.isEligible = isEligible; - }); - } - /** * Refreshes all lending related data including markets, positions, and eligibility. * This method allows partial success, meaning some data may update while other requests fail. @@ -845,15 +871,12 @@ export class EarnController extends BaseController< this.refreshLendingPositions().catch((error) => { errors.push(error); }), - this.refreshLendingEligibility().catch((error) => { - errors.push(error); - }), ]); if (errors.length > 0) { throw new Error( `Failed to refresh some lending data: ${errors - .map((e) => e.message) + .map((error) => error.message) .join(', ')}`, ); } @@ -1019,7 +1042,9 @@ export class EarnController extends BaseController< if (!transactionData) { throw new Error('Transaction data not found'); } - if (!this.#selectedNetworkClientId) { + + const selectedNetworkClientId = this.#getSelectedNetworkClientId(); + if (!selectedNetworkClientId) { throw new Error('Selected network client id not found'); } @@ -1036,7 +1061,7 @@ export class EarnController extends BaseController< }, { ...txOptions, - networkClientId: this.#selectedNetworkClientId, + networkClientId: selectedNetworkClientId, }, ); @@ -1095,7 +1120,8 @@ export class EarnController extends BaseController< throw new Error('Transaction data not found'); } - if (!this.#selectedNetworkClientId) { + const selectedNetworkClientId = this.#getSelectedNetworkClientId(); + if (!selectedNetworkClientId) { throw new Error('Selected network client id not found'); } @@ -1112,7 +1138,7 @@ export class EarnController extends BaseController< }, { ...txOptions, - networkClientId: this.#selectedNetworkClientId, + networkClientId: selectedNetworkClientId, }, ); @@ -1171,7 +1197,8 @@ export class EarnController extends BaseController< throw new Error('Transaction data not found'); } - if (!this.#selectedNetworkClientId) { + const selectedNetworkClientId = this.#getSelectedNetworkClientId(); + if (!selectedNetworkClientId) { throw new Error('Selected network client id not found'); } @@ -1188,7 +1215,7 @@ export class EarnController extends BaseController< }, { ...txOptions, - networkClientId: this.#selectedNetworkClientId, + networkClientId: selectedNetworkClientId, }, ); @@ -1205,7 +1232,7 @@ export class EarnController extends BaseController< async getLendingTokenAllowance( protocol: LendingMarket['protocol'], underlyingTokenAddress: string, - ) { + ): Promise { const address = this.#getSelectedEvmAccountAddress(); if (!address) { @@ -1230,7 +1257,7 @@ export class EarnController extends BaseController< async getLendingTokenMaxWithdraw( protocol: LendingMarket['protocol'], underlyingTokenAddress: string, - ) { + ): Promise { const address = this.#getSelectedEvmAccountAddress(); if (!address) { @@ -1255,7 +1282,7 @@ export class EarnController extends BaseController< async getLendingTokenMaxDeposit( protocol: LendingMarket['protocol'], underlyingTokenAddress: string, - ) { + ): Promise { const address = this.#getSelectedEvmAccountAddress(); if (!address) { diff --git a/packages/earn-controller/src/index.ts b/packages/earn-controller/src/index.ts index 11b366b079a..a81e5525ce5 100644 --- a/packages/earn-controller/src/index.ts +++ b/packages/earn-controller/src/index.ts @@ -29,7 +29,6 @@ export type { EarnControllerRefreshPooledStakingDataAction, EarnControllerRefreshLendingMarketsAction, EarnControllerRefreshLendingPositionsAction, - EarnControllerRefreshLendingEligibilityAction, EarnControllerRefreshLendingDataAction, EarnControllerRefreshTronStakingApyAction, EarnControllerGetTronStakingApyAction, diff --git a/packages/earn-controller/src/types.ts b/packages/earn-controller/src/types.ts index 2c94396941a..0b1f5cb6e21 100644 --- a/packages/earn-controller/src/types.ts +++ b/packages/earn-controller/src/types.ts @@ -22,7 +22,3 @@ export type RefreshPooledStakingVaultDailyApysOptions = { export type RefreshLendingPositionsOptions = { address?: string; }; - -export type RefreshLendingEligibilityOptions = { - address?: string; -};