diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index b59c71a2d1d..b520041b885 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) +- **BREAKING:** bump `@metamask/stake-sdk` peer dependency to `3.0.0` ([#5828](https://github.com/MetaMask/core/pull/5828)) ## [0.14.0] diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index b303ddb5170..41dc4da87f4 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -47,10 +47,12 @@ "since-latest-release": "../../scripts/since-latest-release.sh" }, "dependencies": { + "@ethersproject/bignumber": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/base-controller": "^8.0.1", "@metamask/controller-utils": "^11.9.0", - "@metamask/stake-sdk": "^1.0.0" + "@metamask/stake-sdk": "3.0.0", + "reselect": "^5.1.1" }, "devDependencies": { "@metamask/accounts-controller": "^29.0.0", diff --git a/packages/earn-controller/src/EarnController.test.ts b/packages/earn-controller/src/EarnController.test.ts index cfda436e7d0..d3237f9e565 100644 --- a/packages/earn-controller/src/EarnController.test.ts +++ b/packages/earn-controller/src/EarnController.test.ts @@ -1,8 +1,15 @@ +/* eslint-disable jest/no-conditional-in-test */ import type { AccountsController } from '@metamask/accounts-controller'; import { Messenger } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; import { getDefaultNetworkControllerState } from '@metamask/network-controller'; -import { StakeSdk, StakingApiService } from '@metamask/stake-sdk'; +import { + EarnSdk, + EarnApiService, + type PooledStakingApiService, + type LendingApiService, + type LendingMarket, +} from '@metamask/stake-sdk'; import type { EarnControllerGetStateAction, @@ -17,6 +24,7 @@ import { type EarnControllerActions, type AllowedActions, type AllowedEvents, + DEFAULT_POOLED_STAKING_CHAIN_STATE, } from './EarnController'; import type { TransactionMeta } from '../../transaction-controller/src'; import { @@ -25,23 +33,47 @@ import { } from '../../transaction-controller/src'; jest.mock('@metamask/stake-sdk', () => ({ - StakeSdk: { + EarnSdk: { create: jest.fn().mockImplementation(() => ({ - pooledStakingContract: { - connectSignerOrProvider: jest.fn(), // Mock connectSignerOrProvider + contracts: { + pooledStaking: { + connectSignerOrProvider: jest.fn(), + }, + lending: { + aave: { + '0x123': { + connectSignerOrProvider: jest.fn(), + encodeDepositTransactionData: jest.fn(), + encodeWithdrawTransactionData: jest.fn(), + encodeUnderlyingTokenApproveTransactionData: jest.fn(), + underlyingTokenAllowance: jest.fn(), + maxWithdraw: jest.fn(), + maxDeposit: jest.fn(), + }, + }, + }, }, })), }, - StakingApiService: jest.fn().mockImplementation(() => ({ - getPooledStakes: jest.fn(), - getPooledStakingEligibility: jest.fn(), - getVaultData: jest.fn(), - getVaultDailyApys: jest.fn(), - getVaultApyAverages: jest.fn(), + EarnApiService: jest.fn().mockImplementation(() => ({ + pooledStaking: { + getPooledStakes: jest.fn(), + getPooledStakingEligibility: jest.fn(), + getVaultData: jest.fn(), + getVaultDailyApys: jest.fn(), + getVaultApyAverages: jest.fn(), + getUserDailyRewards: jest.fn(), + }, + lending: { + getMarkets: jest.fn(), + getPositions: jest.fn(), + getPositionHistory: jest.fn(), + getHistoricMarketApys: jest.fn(), + }, })), ChainId: { ETHEREUM: 1, - HOLESKY: 17000, + HOODI: 560048, }, })); @@ -223,15 +255,399 @@ const mockPooledStakingVaultDailyApys = [ ]; const mockPooledStakingVaultApyAverages = { - oneDay: '3.047713358665092375', - oneWeek: '3.25756026351317301786', - oneMonth: '3.25616054301749304217', - threeMonths: '3.31863306662107446672', - sixMonths: '3.05557344496273894133', - oneYear: '0', + oneDay: '1.946455943490720299', + oneWeek: '2.55954569442201844857', + oneMonth: '2.62859516898195124747', + threeMonths: '2.8090492487811444633', + sixMonths: '2.68775113174991540575', + oneYear: '2.58279361113012774176', +}; + +const mockLendingMarkets = [ + { + id: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + chainId: 42161, + protocol: 'aave', + name: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + address: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + netSupplyRate: 1.52269127978874, + totalSupplyRate: 1.52269127978874, + rewards: [], + tvlUnderlying: '132942564710249273623333', + underlying: { + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + chainId: 42161, + }, + outputToken: { + address: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + chainId: 42161, + }, + }, +]; + +const mockLendingPositions = [ + { + id: '0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0', + chainId: 42161, + market: { + id: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + protocol: 'aave', + name: '0x078f358208685046a11c85e8ad32895ded33a249', + address: '0x078f358208685046a11c85e8ad32895ded33a249', + netSupplyRate: 0.0062858302613958, + totalSupplyRate: 0.0062858302613958, + rewards: [], + tvlUnderlying: '315871357755', + underlying: { + address: '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', + chainId: 42161, + }, + outputToken: { + address: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + }, + }, + assets: '112', + }, +]; + +const mockLendingPositionHistory = { + id: '0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0', + chainId: 42161, + market: { + id: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + protocol: 'aave', + name: '0x078f358208685046a11c85e8ad32895ded33a249', + address: '0x078f358208685046a11c85e8ad32895ded33a249', + netSupplyRate: 0.0062857984324433, + totalSupplyRate: 0.0062857984324433, + rewards: [], + tvlUnderlying: '315871357702', + underlying: { + address: '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', + chainId: 42161, + }, + outputToken: { + address: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + }, + }, + assets: '112', + historicalAssets: [ + { + timestamp: 1746835200000, + assets: '112', + }, + { + timestamp: 1746921600000, + assets: '112', + }, + { + timestamp: 1747008000000, + assets: '112', + }, + { + timestamp: 1747094400000, + assets: '112', + }, + { + timestamp: 1747180800000, + assets: '112', + }, + { + timestamp: 1747267200000, + assets: '112', + }, + { + timestamp: 1747353600000, + assets: '112', + }, + { + timestamp: 1747440000000, + assets: '112', + }, + { + timestamp: 1747526400000, + assets: '112', + }, + { + timestamp: 1747612800000, + assets: '112', + }, + ], + lifetimeRewards: [ + { + assets: '0', + token: { + address: '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', + chainId: 42161, + }, + }, + ], +}; + +const mockLendingHistoricMarketApys = { + netSupplyRate: 1.52254256433159, + totalSupplyRate: 1.52254256433159, + averageRates: { + sevenDay: { + netSupplyRate: 1.5282690267043, + totalSupplyRate: 1.5282690267043, + }, + thirtyDay: { + netSupplyRate: 1.655312573822, + totalSupplyRate: 1.655312573822, + }, + ninetyDay: { + netSupplyRate: 1.66478947752133, + totalSupplyRate: 1.66478947752133, + }, + }, + historicalRates: [ + { + timestampSeconds: 1747624157, + netSupplyRate: 1.52254256433159, + totalSupplyRate: 1.52254256433159, + timestamp: 1747624157, + }, + { + timestampSeconds: 1747612793, + netSupplyRate: 1.51830167099938, + totalSupplyRate: 1.51830167099938, + timestamp: 1747612793, + }, + { + timestampSeconds: 1747526383, + netSupplyRate: 1.50642775134808, + totalSupplyRate: 1.50642775134808, + timestamp: 1747526383, + }, + { + timestampSeconds: 1747439883, + netSupplyRate: 1.50747341318386, + totalSupplyRate: 1.50747341318386, + timestamp: 1747439883, + }, + { + timestampSeconds: 1747353586, + netSupplyRate: 1.52147411498283, + totalSupplyRate: 1.52147411498283, + timestamp: 1747353586, + }, + { + timestampSeconds: 1747267154, + netSupplyRate: 1.56669403317425, + totalSupplyRate: 1.56669403317425, + timestamp: 1747267154, + }, + { + timestampSeconds: 1747180788, + netSupplyRate: 1.55496963891012, + totalSupplyRate: 1.55496963891012, + timestamp: 1747180788, + }, + { + timestampSeconds: 1747094388, + netSupplyRate: 1.54239001226593, + totalSupplyRate: 1.54239001226593, + timestamp: 1747094388, + }, + { + timestampSeconds: 1747007890, + netSupplyRate: 1.62851420616391, + totalSupplyRate: 1.62851420616391, + timestamp: 1747007890, + }, + { + timestampSeconds: 1746921596, + netSupplyRate: 1.63674498306057, + totalSupplyRate: 1.63674498306057, + timestamp: 1746921596, + }, + { + timestampSeconds: 1746835148, + netSupplyRate: 1.65760227569609, + totalSupplyRate: 1.65760227569609, + timestamp: 1746835148, + }, + { + timestampSeconds: 1746748786, + netSupplyRate: 1.70873310171041, + totalSupplyRate: 1.70873310171041, + timestamp: 1746748786, + }, + { + timestampSeconds: 1746662367, + netSupplyRate: 1.71305288353747, + totalSupplyRate: 1.71305288353747, + timestamp: 1746662367, + }, + { + timestampSeconds: 1746575992, + netSupplyRate: 1.7197743361477, + totalSupplyRate: 1.7197743361477, + timestamp: 1746575992, + }, + { + timestampSeconds: 1746489584, + netSupplyRate: 1.72394345065358, + totalSupplyRate: 1.72394345065358, + timestamp: 1746489584, + }, + { + timestampSeconds: 1746403148, + netSupplyRate: 1.70886379023728, + totalSupplyRate: 1.70886379023728, + timestamp: 1746403148, + }, + { + timestampSeconds: 1746316798, + netSupplyRate: 1.71429159475843, + totalSupplyRate: 1.71429159475843, + timestamp: 1746316798, + }, + { + timestampSeconds: 1746230392, + netSupplyRate: 1.70443639282888, + totalSupplyRate: 1.70443639282888, + timestamp: 1746230392, + }, + { + timestampSeconds: 1746143902, + netSupplyRate: 1.71396513372792, + totalSupplyRate: 1.71396513372792, + timestamp: 1746143902, + }, + { + timestampSeconds: 1746057521, + netSupplyRate: 1.70397653941133, + totalSupplyRate: 1.70397653941133, + timestamp: 1746057521, + }, + { + timestampSeconds: 1745971133, + netSupplyRate: 1.70153685712654, + totalSupplyRate: 1.70153685712654, + timestamp: 1745971133, + }, + { + timestampSeconds: 1745884780, + netSupplyRate: 1.70574057393751, + totalSupplyRate: 1.70574057393751, + timestamp: 1745884780, + }, + { + timestampSeconds: 1745798140, + netSupplyRate: 1.72724368182558, + totalSupplyRate: 1.72724368182558, + timestamp: 1745798140, + }, + { + timestampSeconds: 1745711975, + netSupplyRate: 1.73661877763414, + totalSupplyRate: 1.73661877763414, + timestamp: 1745711975, + }, + { + timestampSeconds: 1745625539, + netSupplyRate: 1.75079606429804, + totalSupplyRate: 1.75079606429804, + timestamp: 1745625539, + }, + { + timestampSeconds: 1745539193, + netSupplyRate: 1.74336098741825, + totalSupplyRate: 1.74336098741825, + timestamp: 1745539193, + }, + { + timestampSeconds: 1745452777, + netSupplyRate: 1.69211471040769, + totalSupplyRate: 1.69211471040769, + timestamp: 1745452777, + }, + { + timestampSeconds: 1745366392, + netSupplyRate: 1.67734591553397, + totalSupplyRate: 1.67734591553397, + timestamp: 1745366392, + }, + { + timestampSeconds: 1745279933, + netSupplyRate: 1.64722901028615, + totalSupplyRate: 1.64722901028615, + timestamp: 1745279933, + }, + { + timestampSeconds: 1745193577, + netSupplyRate: 1.70321874906262, + totalSupplyRate: 1.70321874906262, + timestamp: 1745193577, + }, + ], }; -const setupController = ({ +const mockUserDailyRewards = [ + { + dailyRewards: '2852081110008', + timestamp: 1746748800000, + dateStr: '2025-05-09', + }, + { + dailyRewards: '2237606324310', + timestamp: 1746835200000, + dateStr: '2025-05-10', + }, + { + dailyRewards: '2622849212844', + timestamp: 1746921600000, + dateStr: '2025-05-11', + }, + { + dailyRewards: '2760026774104', + timestamp: 1747008000000, + dateStr: '2025-05-12', + }, + { + dailyRewards: '2819318182549', + timestamp: 1747094400000, + dateStr: '2025-05-13', + }, + { + dailyRewards: '3526676051496', + timestamp: 1747180800000, + dateStr: '2025-05-14', + }, + { + dailyRewards: '3328845644827', + timestamp: 1747267200000, + dateStr: '2025-05-15', + }, + { + dailyRewards: '3364955138474', + timestamp: 1747353600000, + dateStr: '2025-05-16', + }, + { + dailyRewards: '2862320970705', + timestamp: 1747440000000, + dateStr: '2025-05-17', + }, + { + dailyRewards: '2999711064948', + timestamp: 1747526400000, + dateStr: '2025-05-18', + }, + { + dailyRewards: '0', + timestamp: 1747612800000, + dateStr: '2025-05-19', + }, +]; + +const setupController = async ({ options = {}, mockGetNetworkClientById = jest.fn(() => ({ @@ -253,11 +669,14 @@ const setupController = ({ mockGetSelectedAccount = jest.fn(() => ({ address: mockAccount1Address, })), + + addTransactionFn = jest.fn(), }: { options?: Partial[0]>; mockGetNetworkClientById?: jest.Mock; mockGetNetworkControllerState?: jest.Mock; mockGetSelectedAccount?: jest.Mock; + addTransactionFn?: jest.Mock; } = {}) => { const messenger = buildMessenger(); @@ -279,67 +698,78 @@ const setupController = ({ const controller = new EarnController({ messenger: earnControllerMessenger, ...options, + addTransactionFn, }); return { controller, messenger }; }; -const StakingApiServiceMock = jest.mocked(StakingApiService); -let mockedStakingApiService: Partial; +const EarnApiServiceMock = jest.mocked(EarnApiService); +let mockedEarnApiService: Partial; describe('EarnController', () => { beforeEach(() => { jest.clearAllMocks(); - // Apply StakeSdk mock before initializing EarnController - (StakeSdk.create as jest.Mock).mockImplementation(() => ({ - pooledStakingContract: { - connectSignerOrProvider: jest.fn(), + // Apply EarnSdk mock before initializing EarnController + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + pooledStaking: null, + lending: null, }, })); - mockedStakingApiService = { - getPooledStakes: jest.fn().mockResolvedValue({ - accounts: [mockPooledStakes], - exchangeRate: '1.5', - }), - getPooledStakingEligibility: jest.fn().mockResolvedValue({ - eligible: true, - }), - getVaultData: jest.fn().mockResolvedValue(mockVaultMetadata), - getVaultDailyApys: jest - .fn() - .mockResolvedValue(mockPooledStakingVaultDailyApys), - getVaultApyAverages: jest - .fn() - .mockResolvedValue(mockPooledStakingVaultApyAverages), - } as Partial; - - StakingApiServiceMock.mockImplementation( - () => mockedStakingApiService as StakingApiService, + mockedEarnApiService = { + pooledStaking: { + getPooledStakes: jest.fn().mockResolvedValue({ + accounts: [mockPooledStakes], + exchangeRate: '1.5', + }), + getPooledStakingEligibility: jest.fn().mockResolvedValue({ + eligible: true, + }), + getVaultData: jest.fn().mockResolvedValue(mockVaultMetadata), + getVaultDailyApys: jest + .fn() + .mockResolvedValue(mockPooledStakingVaultDailyApys), + getVaultApyAverages: jest + .fn() + .mockResolvedValue(mockPooledStakingVaultApyAverages), + getUserDailyRewards: jest.fn().mockResolvedValue(mockUserDailyRewards), + } as Partial, + lending: { + getMarkets: jest.fn().mockResolvedValue(mockLendingMarkets), + getPositions: jest.fn().mockResolvedValue(mockLendingPositions), + getPositionHistory: jest + .fn() + .mockResolvedValue(mockLendingPositionHistory), + getHistoricMarketApys: jest + .fn() + .mockResolvedValue(mockLendingHistoricMarketApys), + } as Partial, + } as Partial; + + EarnApiServiceMock.mockImplementation( + () => mockedEarnApiService as EarnApiService, ); }); describe('constructor', () => { - it('initializes with default state when no state is provided', () => { - const { controller } = setupController(); + it('initializes with default state when no state is provided', async () => { + const { controller } = await setupController(); expect(controller.state).toStrictEqual(getDefaultEarnControllerState()); }); - it('uses provided state to initialize', () => { + it('uses provided state to initialize', async () => { const customState: Partial = { pooled_staking: { - pooledStakes: mockPooledStakes, - exchangeRate: '1.5', - vaultMetadata: mockVaultMetadata, + '0': DEFAULT_POOLED_STAKING_CHAIN_STATE, isEligible: true, - vaultDailyApys: mockPooledStakingVaultDailyApys, - vaultApyAverages: mockPooledStakingVaultApyAverages, }, lastUpdated: 1234567890, }; - const { controller } = setupController({ + const { controller } = await setupController({ options: { state: customState }, }); @@ -351,41 +781,39 @@ describe('EarnController', () => { }); describe('SDK initialization', () => { - it('initializes SDK with correct chain ID on construction', () => { - setupController(); - expect(StakeSdk.create).toHaveBeenCalledWith({ + it('initializes SDK with correct chain ID on construction', async () => { + await setupController(); + expect(EarnSdk.create).toHaveBeenCalledWith(expect.any(Object), { chainId: 1, }); }); - it('handles SDK initialization failure gracefully by avoiding known errors', () => { + it('handles SDK initialization failure gracefully by avoiding known errors', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - (StakeSdk.create as jest.Mock).mockImplementationOnce(() => { + (EarnSdk.create as jest.Mock).mockImplementationOnce(() => { throw new Error('Unsupported chainId'); }); // Unsupported chain id should not result in console error statement - setupController(); + await setupController(); expect(consoleErrorSpy).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); - it('handles SDK initialization failure gracefully by logging error', () => { + it('handles SDK initialization failure gracefully by logging error', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - (StakeSdk.create as jest.Mock).mockImplementationOnce(() => { + (EarnSdk.create as jest.Mock).mockImplementationOnce(() => { throw new Error('Network error'); }); // Unexpected error should be logged - setupController(); + await setupController(); expect(consoleErrorSpy).toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); - // TEMP: We're hardcoding ETH mainnet since we can't rely on the network picker anymore. - // eslint-disable-next-line jest/no-disabled-tests - it.skip('reinitializes SDK when network changes', () => { - const { messenger } = setupController(); + it('reinitializes SDK when network changes', async () => { + const { messenger } = await setupController(); messenger.publish( 'NetworkController:stateChange', @@ -396,220 +824,376 @@ describe('EarnController', () => { [], ); - expect(StakeSdk.create).toHaveBeenCalledTimes(2); - expect(mockedStakingApiService.getPooledStakes).toHaveBeenCalled(); + expect(EarnSdk.create).toHaveBeenCalledTimes(2); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalled(); }); - it('does not initialize sdk if the provider is null', () => { - setupController({ + it('does not initialize sdk if the provider is null', async () => { + await setupController({ mockGetNetworkClientById: jest.fn(() => ({ provider: null, configuration: { chainId: '0x1' }, })), }); - expect(StakeSdk.create).not.toHaveBeenCalled(); + expect(EarnSdk.create).not.toHaveBeenCalled(); }); }); - describe('refreshPooledStakingData', () => { - it('updates state with fetched staking data', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakingData(); + describe('Pooled Staking', () => { + describe('refreshPooledStakingData', () => { + it('updates state with fetched staking data', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData(); + + expect(controller.state.pooled_staking).toMatchObject({ + '1': { + pooledStakes: mockPooledStakes, + exchangeRate: '1.5', + vaultMetadata: mockVaultMetadata, + vaultDailyApys: mockPooledStakingVaultDailyApys, + vaultApyAverages: mockPooledStakingVaultApyAverages, + }, + isEligible: true, + }); + expect(controller.state.lastUpdated).toBeDefined(); + }); + + it('does not invalidate cache when refreshing state', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData(); - expect(controller.state.pooled_staking).toStrictEqual({ - pooledStakes: mockPooledStakes, - exchangeRate: '1.5', - vaultMetadata: mockVaultMetadata, - vaultDailyApys: mockPooledStakingVaultDailyApys, - vaultApyAverages: mockPooledStakingVaultApyAverages, - isEligible: true, + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + // First call occurs during setupController() + 2, + [mockAccount1Address], + 1, + false, + ); }); - expect(controller.state.lastUpdated).toBeDefined(); - }); - it('does not invalidate cache when refreshing state', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakingData(); + it('invalidates cache when refreshing state', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData({ resetCache: true }); - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - // First call occurs during setupController() - 2, - [mockAccount1Address], - 1, - false, - ); - }); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + // First call occurs during setupController() + 2, + [mockAccount1Address], + 1, + true, + ); + }); - it('invalidates cache when refreshing state', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakingData({ resetCache: true }); + it('refreshes state using options.address', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData({ + address: mockAccount2Address, + }); - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - // First call occurs during setupController() - 2, - [mockAccount1Address], - 1, - true, - ); - }); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + // First call occurs during setupController() + 2, + [mockAccount2Address], + 1, + false, + ); + }); + + it('handles API errors gracefully', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + mockedEarnApiService = { + pooledStaking: { + getPooledStakes: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + getPooledStakingEligibility: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + getVaultData: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + getVaultDailyApys: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + getVaultApyAverages: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + getUserDailyRewards: jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }), + } as unknown as PooledStakingApiService, + }; + + EarnApiServiceMock.mockImplementation( + () => mockedEarnApiService as EarnApiService, + ); + + const { controller } = await setupController(); - it('refreshes state using options.address', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakingData({ - address: mockAccount2Address, + await expect(controller.refreshPooledStakingData()).rejects.toThrow( + 'Failed to refresh some staking data: API Error, API Error, API Error', + ); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); }); - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - // First call occurs during setupController() - 2, - [mockAccount2Address], - 1, - false, - ); + // if no account is selected, it should not fetch stakes data but still update vault metadata, vault daily apys and vault apy averages. + it('does not fetch staking data if no account is selected', async () => { + const { controller } = await setupController({ + mockGetSelectedAccount: jest.fn(() => null), + }); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).not.toHaveBeenCalled(); + + await controller.refreshPooledStakingData(); + expect(controller.state.pooled_staking[1].pooledStakes).toStrictEqual( + DEFAULT_POOLED_STAKING_CHAIN_STATE.pooledStakes, + ); + expect(controller.state.pooled_staking[1].vaultMetadata).toStrictEqual( + mockVaultMetadata, + ); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + expect( + controller.state.pooled_staking[1].vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); + expect(controller.state.pooled_staking.isEligible).toBe(false); + }); }); - it('handles API errors gracefully', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - mockedStakingApiService = { - getPooledStakes: jest.fn().mockImplementation(() => { - throw new Error('API Error'); - }), - getPooledStakingEligibility: jest.fn().mockImplementation(() => { - throw new Error('API Error'); - }), - getVaultData: jest.fn().mockImplementation(() => { - throw new Error('API Error'); - }), - }; + describe('refreshPooledStakes', () => { + it('fetches without resetting cache when resetCache is false', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes({ resetCache: false }); - StakingApiServiceMock.mockImplementation( - () => mockedStakingApiService as StakingApiService, - ); + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith(2, [mockAccount1Address], 1, false); + }); - const { controller } = setupController(); + it('fetches without resetting cache when resetCache is undefined', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes(); - await expect(controller.refreshPooledStakingData()).rejects.toThrow( - 'Failed to refresh some staking data: API Error, API Error, API Error', - ); - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); - }); + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith(2, [mockAccount1Address], 1, false); + }); - // if no account is selected, it should not fetch stakes data but still update vault metadata, vault daily apys and vault apy averages. - it('does not fetch staking data if no account is selected', async () => { - const { controller } = setupController({ - mockGetSelectedAccount: jest.fn(() => null), + it('fetches while resetting cache', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes({ resetCache: true }); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith(2, [mockAccount1Address], 1, true); }); - expect(mockedStakingApiService.getPooledStakes).not.toHaveBeenCalled(); + it('fetches using active account (default)', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes(); - await controller.refreshPooledStakingData(); + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith(2, [mockAccount1Address], 1, false); + }); - expect(controller.state.pooled_staking.pooledStakes).toStrictEqual( - getDefaultEarnControllerState().pooled_staking.pooledStakes, - ); - expect(controller.state.pooled_staking.vaultMetadata).toStrictEqual( - mockVaultMetadata, - ); - expect(controller.state.pooled_staking.vaultDailyApys).toStrictEqual( - mockPooledStakingVaultDailyApys, - ); - expect(controller.state.pooled_staking.vaultApyAverages).toStrictEqual( - mockPooledStakingVaultApyAverages, - ); - expect(controller.state.pooled_staking.isEligible).toBe(false); - }); - }); + it('fetches using options.address override', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes({ address: mockAccount2Address }); - describe('refreshPooledStakes', () => { - it('fetches without resetting cache when resetCache is false', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakes({ resetCache: false }); - - // Assertion on second call since the first is part of controller setup. - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - 2, - [mockAccount1Address], - 1, - false, - ); + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith(2, [mockAccount2Address], 1, false); + }); }); - it('fetches without resetting cache when resetCache is undefined', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakes(); + describe('refreshStakingEligibility', () => { + it('fetches staking eligibility using active account (default)', async () => { + const { controller } = await setupController(); - // Assertion on second call since the first is part of controller setup. - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - 2, - [mockAccount1Address], - 1, - false, - ); - }); + await controller.refreshStakingEligibility(); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenNthCalledWith(2, [mockAccount1Address]); + }); - it('fetches while resetting cache', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakes({ resetCache: true }); + it('fetches staking eligibility using options.address override', async () => { + const { controller } = await setupController(); + await controller.refreshStakingEligibility({ + address: mockAccount2Address, + }); - // Assertion on second call since the first is part of controller setup. - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - 2, - [mockAccount1Address], - 1, - true, - ); + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenNthCalledWith(3, [mockAccount2Address]); + }); }); - it('fetches using active account (default)', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakes(); + describe('refreshPooledStakingVaultMetadata', () => { + it('refreshes vault metadata', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultMetadata(); - // Assertion on second call since the first is part of controller setup. - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - 2, - [mockAccount1Address], - 1, - false, - ); + expect( + mockedEarnApiService?.pooledStaking?.getVaultData, + ).toHaveBeenCalledTimes(2); + }); }); - it('fetches using options.address override', async () => { - const { controller } = setupController(); - await controller.refreshPooledStakes({ address: mockAccount2Address }); + describe('refreshPooledStakingVaultDailyApys', () => { + it('refreshes vault daily apys', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys(); - // Assertion on second call since the first is part of controller setup. - expect(mockedStakingApiService.getPooledStakes).toHaveBeenNthCalledWith( - 2, - [mockAccount2Address], - 1, - false, - ); - }); - }); + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenCalledTimes(2); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); - describe('refreshStakingEligibility', () => { - it('fetches staking eligibility using active account (default)', async () => { - const { controller } = setupController(); + it('refreshes vault daily apys with passed chainId', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys(1); - await controller.refreshStakingEligibility(); + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 365, 'desc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); - // Assertion on second call since the first is part of controller setup. - expect( - mockedStakingApiService.getPooledStakingEligibility, - ).toHaveBeenNthCalledWith(2, [mockAccount1Address]); + it('refreshes vault daily apys with custom days', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys(1, 180); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 180, 'desc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); + + it('refreshes vault daily apys with ascending order', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys(1, 365, 'asc'); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 365, 'asc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); + + it('refreshes vault daily apys with custom days and ascending order', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys(1, 180, 'asc'); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 180, 'asc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); + + it('refreshes vault daily apys with different network client id', async () => { + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + selectedNetworkClientId: '2', + networkConfigurations: { + '2': { chainId: '0x2' }, + }, + })), + mockGetNetworkClientById: jest.fn(() => ({ + configuration: { chainId: '0x2' }, + })), + }); + + await controller.refreshPooledStakingVaultDailyApys(); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 2, 365, 'desc'); + expect(controller.state.pooled_staking[2].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); }); - it('fetches staking eligibility using options.address override', async () => { - const { controller } = setupController(); - await controller.refreshStakingEligibility({ - address: mockAccount2Address, + describe('refreshPooledStakingVaultApyAverages', () => { + it('refreshes vault apy averages', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultApyAverages(); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultApyAverages, + ).toHaveBeenCalledTimes(2); + expect( + controller.state.pooled_staking[1].vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); }); - // Assertion on second call since the first is part of controller setup. - expect( - mockedStakingApiService.getPooledStakingEligibility, - ).toHaveBeenNthCalledWith(2, [mockAccount2Address]); + it('refreshes vault apy averages with passed chainId', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultApyAverages(1); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultApyAverages, + ).toHaveBeenNthCalledWith(2, 1); + expect( + controller.state.pooled_staking[1].vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); + }); + + it('refreshes vault apy averages with different network client id', async () => { + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + selectedNetworkClientId: '2', + networkConfigurations: { + '2': { chainId: '0x2' }, + }, + })), + mockGetNetworkClientById: jest.fn(() => ({ + configuration: { chainId: '0x2' }, + })), + }); + + await controller.refreshPooledStakingVaultApyAverages(); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultApyAverages, + ).toHaveBeenNthCalledWith(2, 2); + expect( + controller.state.pooled_staking[2].vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); + }); }); }); @@ -619,8 +1203,8 @@ describe('EarnController', () => { }); describe('On network change', () => { - it('updates vault data when network changes', () => { - const { controller, messenger } = setupController(); + it('updates vault data when network changes', async () => { + const { controller, messenger } = await setupController(); jest .spyOn(controller, 'refreshPooledStakingVaultMetadata') @@ -658,8 +1242,8 @@ describe('EarnController', () => { describe('On selected account change', () => { // TEMP: Workaround for issue: https://github.com/MetaMask/accounts-planning/issues/887 - it('uses event payload account address to update staking eligibility', () => { - const { controller, messenger } = setupController(); + it('uses event payload account address to update staking eligibility', async () => { + const { controller, messenger } = await setupController(); jest.spyOn(controller, 'refreshStakingEligibility').mockResolvedValue(); jest.spyOn(controller, 'refreshPooledStakes').mockResolvedValue(); @@ -683,12 +1267,13 @@ describe('EarnController', () => { EarnControllerStateChangeEvent | AllowedEvents >; - beforeEach(() => { - const earnController = setupController(); + beforeEach(async () => { + const earnController = await setupController(); + await new Promise((resolve) => setTimeout(resolve, 0)); controller = earnController.controller; messenger = earnController.messenger; - jest.spyOn(controller, 'refreshPooledStakes').mockResolvedValue(); + jest.spyOn(controller, 'refreshLendingPositions').mockResolvedValue(); }); it('updates pooled stakes for staking deposit transaction type', () => { @@ -742,7 +1327,39 @@ describe('EarnController', () => { }); }); - it('ignores non-staking transaction types', () => { + it('updates lending positions for lending deposit transaction type', () => { + const MOCK_CONFIRMED_DEPOSIT_TX = createMockTransaction({ + type: TransactionType.lendingDeposit, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_DEPOSIT_TX, + ); + + expect(controller.refreshLendingPositions).toHaveBeenNthCalledWith(1, { + address: MOCK_CONFIRMED_DEPOSIT_TX.txParams.from, + }); + }); + + it('updates lending positions for lending withdraw transaction type', () => { + const MOCK_CONFIRMED_WITHDRAW_TX = createMockTransaction({ + type: 'lendingWithdraw' as TransactionType, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_WITHDRAW_TX, + ); + + expect(controller.refreshLendingPositions).toHaveBeenNthCalledWith(1, { + address: MOCK_CONFIRMED_WITHDRAW_TX.txParams.from, + }); + }); + + it('ignores non-staking and non-lending transaction types', () => { const MOCK_CONFIRMED_SWAP_TX = createMockTransaction({ type: TransactionType.swap, status: TransactionStatus.confirmed, @@ -754,6 +1371,627 @@ describe('EarnController', () => { ); expect(controller.refreshPooledStakes).toHaveBeenCalledTimes(0); + expect(controller.refreshLendingPositions).toHaveBeenCalledTimes(0); + }); + }); + }); + + 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(); + await controller.refreshLendingPositions(); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.lending?.getPositions, + ).toHaveBeenNthCalledWith(2, mockAccount1Address); + }); + + it('fetches using options.address override', async () => { + const { controller } = await setupController(); + await controller.refreshLendingPositions({ + address: mockAccount2Address, + }); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.lending?.getPositions, + ).toHaveBeenNthCalledWith(2, mockAccount2Address); + }); + }); + + describe('refreshLendingMarkets', () => { + it('fetches lending markets', async () => { + const { controller } = await setupController(); + await controller.refreshLendingMarkets(); + + // Assertion on second call since the first is part of controller setup. + expect(mockedEarnApiService?.lending?.getMarkets).toHaveBeenCalledTimes( + 2, + ); + }); + }); + + describe('refreshLendingData', () => { + it('refreshes lending data', async () => { + const { controller } = await setupController(); + await controller.refreshLendingData(); + + // Assertion on second call since the first is part of controller setup. + expect(mockedEarnApiService?.lending?.getMarkets).toHaveBeenCalledTimes( + 2, + ); + expect( + mockedEarnApiService?.lending?.getPositions, + ).toHaveBeenCalledTimes(2); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenCalledTimes(4); + }); + }); + + describe('getLendingPositionHistory', () => { + it('gets lending position history', async () => { + const { controller } = await setupController(); + const mockPositionHistory = [ + { + id: '1', + timestamp: '2024-02-20T00:00:00.000Z', + type: 'deposit', + amount: '100', + }, + ]; + + expect(mockedEarnApiService.lending).toBeDefined(); + + ( + (mockedEarnApiService.lending as LendingApiService) + .getPositionHistory as jest.Mock + ).mockResolvedValue(mockPositionHistory); + + const result = await controller.getLendingPositionHistory({ + positionId: '1', + marketId: 'market1', + marketAddress: '0x123', + protocol: 'aave' as LendingMarket['protocol'], + }); + + expect(result).toStrictEqual(mockPositionHistory); + expect( + (mockedEarnApiService.lending as LendingApiService) + .getPositionHistory, + ).toHaveBeenCalledWith( + mockAccount1Address, + 1, + 'aave', + 'market1', + '0x123', + '1', + 730, + ); + }); + + it('returns empty array if no address is provided', async () => { + const { controller } = await setupController({ + mockGetSelectedAccount: jest.fn(() => ({ + address: null, + })), + }); + const result = await controller.getLendingPositionHistory({ + positionId: '1', + marketId: 'market1', + marketAddress: '0x123', + protocol: 'aave' as LendingMarket['protocol'], + }); + + expect(result).toStrictEqual([]); + }); + }); + + describe('getLendingMarketDailyApysAndAverages', () => { + it('gets lending market daily apys and averages', async () => { + const { controller } = await setupController(); + const mockApysAndAverages = { + dailyApys: [ + { + id: 1, + timestamp: '2024-02-20T00:00:00.000Z', + apy: '5.5', + }, + ], + averages: { + oneDay: '5.5', + oneWeek: '5.5', + oneMonth: '5.5', + threeMonths: '5.5', + sixMonths: '5.5', + oneYear: '5.5', + }, + }; + + if (!mockedEarnApiService.lending) { + throw new Error('Lending service not initialized'); + } + + ( + mockedEarnApiService.lending.getHistoricMarketApys as jest.Mock + ).mockResolvedValue(mockApysAndAverages); + + const result = await controller.getLendingMarketDailyApysAndAverages({ + protocol: 'aave' as LendingMarket['protocol'], + marketId: 'market1', + }); + + expect(result).toStrictEqual(mockApysAndAverages); + expect( + mockedEarnApiService.lending.getHistoricMarketApys, + ).toHaveBeenCalledWith(1, 'aave', 'market1', 365); + }); + }); + + describe('executeLendingDeposit', () => { + it('executes lending deposit transaction', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + + gasLimit: '100000', + }; + const mockLendingContract = { + encodeDepositTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + addTransactionFn: jest.fn().mockResolvedValue('successfulhash'), + }); + + const result = await controller.executeLendingDeposit({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeDepositTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + }); + + it('handles error when encodeDepositTransactionData throws', async () => { + const contractError = new Error('Contract Error'); + const mockLendingContract = { + encodeDepositTransactionData: jest + .fn() + .mockRejectedValue(contractError), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + await expect( + controller.executeLendingDeposit({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow(contractError); + }); + + it('handles transaction data not found', async () => { + const { controller } = await setupController(); + await expect( + controller.executeLendingDeposit({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Transaction data not found'); + }); + + it('handles selected network client id not found', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: '100000', + }; + const mockLendingContract = { + encodeDepositTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + selectedNetworkClientId: null, + networkConfigurations: {}, + })), + }); + + await expect( + controller.executeLendingDeposit({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Selected network client id not found'); + }); + }); + + describe('executeLendingWithdraw', () => { + it('executes lending withdraw transaction', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: '100000', + }; + + const mockLendingContract = { + encodeWithdrawTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + addTransactionFn: jest.fn().mockResolvedValue('successfulhash'), + }); + + const result = await controller.executeLendingWithdraw({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeWithdrawTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + }); + + it('handles transaction data not found', async () => { + const { controller } = await setupController(); + await expect( + controller.executeLendingWithdraw({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Transaction data not found'); + }); + + it('handles selected network client id not found', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: '100000', + }; + const mockLendingContract = { + encodeWithdrawTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + selectedNetworkClientId: null, + networkConfigurations: {}, + })), + }); + + await expect( + controller.executeLendingWithdraw({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Selected network client id not found'); + }); + }); + + describe('executeLendingTokenApprove', () => { + it('executes lending token approve transaction', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: '100000', + }; + + const mockLendingContract = { + encodeUnderlyingTokenApproveTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + addTransactionFn: jest.fn().mockResolvedValue('successfulhash'), + }); + + const result = await controller.executeLendingTokenApprove({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeUnderlyingTokenApproveTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + }); + + it('handles transaction data not found', async () => { + const { controller } = await setupController(); + await expect( + controller.executeLendingTokenApprove({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Transaction data not found'); + }); + + it('handles selected network client id not found', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: '100000', + }; + const mockLendingContract = { + encodeUnderlyingTokenApproveTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + selectedNetworkClientId: null, + networkConfigurations: {}, + })), + }); + + await expect( + controller.executeLendingTokenApprove({ + amount: '100', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Selected network client id not found'); + }); + }); + + describe('getLendingTokenAllowance', () => { + it('gets lending token allowance', async () => { + const mockAllowance = '1000'; + + const mockLendingContract = { + underlyingTokenAllowance: jest.fn().mockResolvedValue(mockAllowance), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + const result = await controller.getLendingTokenAllowance( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect( + mockLendingContract.underlyingTokenAllowance, + ).toHaveBeenCalledWith(mockAccount1Address); + expect(result).toBe(mockAllowance); + }); + }); + + describe('getLendingTokenMaxWithdraw', () => { + it('gets lending token max withdraw', async () => { + const mockMaxWithdraw = '1000'; + + const mockLendingContract = { + maxWithdraw: jest.fn().mockResolvedValue(mockMaxWithdraw), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + const result = await controller.getLendingTokenMaxWithdraw( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect(mockLendingContract.maxWithdraw).toHaveBeenCalledWith( + mockAccount1Address, + ); + expect(result).toBe(mockMaxWithdraw); + }); + }); + + describe('getLendingTokenMaxDeposit', () => { + it('gets lending token max deposit', async () => { + const mockMaxDeposit = '1000'; + + const mockLendingContract = { + maxDeposit: jest.fn().mockResolvedValue(mockMaxDeposit), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + const result = await controller.getLendingTokenMaxDeposit( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect(mockLendingContract.maxDeposit).toHaveBeenCalledWith( + mockAccount1Address, + ); + expect(result).toBe(mockMaxDeposit); }); }); }); diff --git a/packages/earn-controller/src/EarnController.ts b/packages/earn-controller/src/EarnController.ts index 9566462b2f4..2b0911dd1c6 100644 --- a/packages/earn-controller/src/EarnController.ts +++ b/packages/earn-controller/src/EarnController.ts @@ -10,28 +10,33 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { convertHexToDecimal } from '@metamask/controller-utils'; +import { convertHexToDecimal, toHex } from '@metamask/controller-utils'; import type { NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; import { - StakeSdk, - StakingApiService, + EarnSdk, + EarnApiService, + type LendingMarket, type PooledStake, - type StakeSdkConfig, + type EarnSdkConfig, type VaultData, type VaultDailyApy, type VaultApyAverages, - ChainId, + type LendingPosition, + type GasLimitParams, } from '@metamask/stake-sdk'; import { + type TransactionController, TransactionType, type TransactionControllerTransactionConfirmedEvent, } from '@metamask/transaction-controller'; import type { + RefreshLendingEligibilityOptions, + RefreshLendingPositionsOptions, RefreshPooledStakesOptions, RefreshPooledStakingDataOptions, RefreshStakingEligibilityOptions, @@ -40,27 +45,40 @@ import type { export const controllerName = 'EarnController'; export type PooledStakingState = { - pooledStakes: PooledStake; - exchangeRate: string; - vaultMetadata: VaultData; - vaultDailyApys: VaultDailyApy[]; - vaultApyAverages: VaultApyAverages; + [chainId: number]: { + pooledStakes: PooledStake; + exchangeRate: string; + vaultMetadata: VaultData; + vaultDailyApys: VaultDailyApy[]; + vaultApyAverages: VaultApyAverages; + }; isEligible: boolean; }; -export type StablecoinLendingState = { - vaults: StablecoinVault[]; +export type LendingPositionWithMarket = LendingPosition & { + marketId: string; + marketAddress: string; + protocol: string; +}; + +// extends LendingPosition to include a marketId, marketAddress, and protocol reference +export type LendingPositionWithMarketReference = Omit< + LendingPosition, + 'market' +> & { + marketId: string; + marketAddress: string; + protocol: string; +}; + +export type LendingMarketWithPosition = LendingMarket & { + position: LendingPositionWithMarketReference; }; -export type StablecoinVault = { - symbol: string; - name: string; - chainId: number; - tokenAddress: string; - vaultAddress: string; - currentAPY: string; - supply: string; - liquidity: string; +export type LendingState = { + markets: LendingMarket[]; // list of markets + positions: LendingPositionWithMarketReference[]; // list of positions + isEligible: boolean; }; type StakingTransactionTypes = @@ -74,6 +92,15 @@ const stakingTransactionTypes = new Set([ TransactionType.stakingClaim, ]); +type LendingTransactionTypes = + | TransactionType.lendingDeposit + | 'lendingWithdraw'; + +const lendingTransactionTypes = new Set([ + TransactionType.lendingDeposit, + 'lendingWithdraw', +]); + /** * Metadata for the EarnController. */ @@ -82,7 +109,7 @@ const earnControllerMetadata: StateMetadata = { persist: true, anonymous: false, }, - stablecoin_lending: { + lending: { persist: true, anonymous: false, }, @@ -95,23 +122,49 @@ const earnControllerMetadata: StateMetadata = { // === State Types === export type EarnControllerState = { pooled_staking: PooledStakingState; - stablecoin_lending?: StablecoinLendingState; + lending: LendingState; lastUpdated: number; }; // === Default State === -const DEFAULT_STABLECOIN_VAULT: StablecoinVault = { - symbol: '', +export const DEFAULT_LENDING_MARKET: LendingMarket = { + id: '', + chainId: 0, + protocol: '' as LendingMarket['protocol'], name: '', + address: '', + tvlUnderlying: '0', + netSupplyRate: 0, + totalSupplyRate: 0, + underlying: { + address: '', + chainId: 0, + }, + outputToken: { + address: '', + chainId: 0, + }, + rewards: [ + { + token: { + address: '', + chainId: 0, + }, + rate: 0, + }, + ], +}; + +export const DEFAULT_LENDING_POSITION: LendingPositionWithMarketReference = { + id: '', chainId: 0, - tokenAddress: '', - vaultAddress: '', - currentAPY: '0', - supply: '0', - liquidity: '0', + assets: '0', + marketId: '', + marketAddress: '', + protocol: '', }; -const DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES: VaultApyAverages = { +export const DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES: VaultApyAverages = { oneDay: '0', oneWeek: '0', oneMonth: '0', @@ -120,6 +173,25 @@ const DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES: VaultApyAverages = { oneYear: '0', }; +export const DEFAULT_POOLED_STAKING_CHAIN_STATE = { + pooledStakes: { + account: '', + lifetimeRewards: '0', + assets: '0', + exitRequests: [], + }, + exchangeRate: '1', + vaultMetadata: { + apy: '0', + capacity: '0', + feePercent: 0, + totalAssets: '0', + vaultAddress: '0x0000000000000000000000000000000000000000', + }, + vaultDailyApys: [], + vaultApyAverages: DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES, +}; + /** * Gets the default state for the EarnController. * @@ -128,26 +200,12 @@ const DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES: VaultApyAverages = { export function getDefaultEarnControllerState(): EarnControllerState { return { pooled_staking: { - pooledStakes: { - account: '', - lifetimeRewards: '0', - assets: '0', - exitRequests: [], - }, - exchangeRate: '1', - vaultMetadata: { - apy: '0', - capacity: '0', - feePercent: 0, - totalAssets: '0', - vaultAddress: '0x0000000000000000000000000000000000000000', - }, - vaultDailyApys: [], - vaultApyAverages: DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES, isEligible: false, }, - stablecoin_lending: { - vaults: [DEFAULT_STABLECOIN_VAULT], + lending: { + markets: [DEFAULT_LENDING_MARKET], + positions: [DEFAULT_LENDING_POSITION], + isEligible: false, }, lastUpdated: 0, }; @@ -219,18 +277,24 @@ export class EarnController extends BaseController< EarnControllerState, EarnControllerMessenger > { - #stakeSDK: StakeSdk | null = null; + #earnSDK: EarnSdk | null = null; #selectedNetworkClientId?: string; - readonly #stakingApiService: StakingApiService = new StakingApiService(); + readonly #earnApiService: EarnApiService = new EarnApiService(); + + readonly #addTransactionFn: typeof TransactionController.prototype.addTransaction; + + readonly #supportedPooledStakingChains: number[]; constructor({ messenger, state = {}, + addTransactionFn, }: { messenger: EarnControllerMessenger; state?: Partial; + addTransactionFn: typeof TransactionController.prototype.addTransaction; }) { super({ name: controllerName, @@ -242,8 +306,16 @@ export class EarnController extends BaseController< }, }); - this.#initializeSDK(); + // temporary array of supported chains + // TODO: remove this once we have a more permanent solution + // from sdk or api to get lending and pooled staking chains + this.#supportedPooledStakingChains = [1, 560048]; + + this.#addTransactionFn = addTransactionFn; + + this.#initializeSDK().catch(console.error); this.refreshPooledStakingData().catch(console.error); + this.refreshLendingData().catch(console.error); const { selectedNetworkClientId } = this.messagingSystem.call( 'NetworkController:getState', @@ -258,11 +330,28 @@ export class EarnController extends BaseController< networkControllerState.selectedNetworkClientId !== this.#selectedNetworkClientId ) { - this.#initializeSDK(networkControllerState.selectedNetworkClientId); - this.refreshPooledStakingVaultMetadata().catch(console.error); - this.refreshPooledStakingVaultDailyApys().catch(console.error); - this.refreshPooledStakingVaultApyAverages().catch(console.error); - this.refreshPooledStakes().catch(console.error); + const chainId = this.#getCurrentChainId( + networkControllerState.selectedNetworkClientId, + ); + this.#initializeSDK( + networkControllerState.selectedNetworkClientId, + ).catch(console.error); + if (this.#supportedPooledStakingChains.includes(chainId)) { + // only refresh pool staking data for the chain we are switching to + this.refreshPooledStakingVaultMetadata(chainId).catch( + console.error, + ); + this.refreshPooledStakingVaultDailyApys(chainId).catch( + console.error, + ); + this.refreshPooledStakingVaultApyAverages(chainId).catch( + console.error, + ); + this.refreshPooledStakes({ chainId }).catch(console.error); + } + // refresh lending data for all chains + this.refreshLendingMarkets().catch(console.error); + this.refreshLendingPositions().catch(console.error); } this.#selectedNetworkClientId = networkControllerState.selectedNetworkClientId; @@ -279,8 +368,13 @@ export class EarnController extends BaseController< * Until this has been fixed, we rely on the event payload for the latest account instead of #getCurrentAccount(). * Issue: https://github.com/MetaMask/accounts-planning/issues/887 */ + + // TODO: temp solution, this will refresh lending eligibility also + // we could have a more general check, as what is happening is a compliance address check this.refreshStakingEligibility({ address }).catch(console.error); + this.refreshPooledStakes({ address }).catch(console.error); + this.refreshLendingPositions({ address }).catch(console.error); }, ); @@ -299,17 +393,32 @@ export class EarnController extends BaseController< stakingTransactionTypes.has(type as StakingTransactionTypes) || stakingTransactionTypes.has(originalType as StakingTransactionTypes); + const isLendingTransaction = + lendingTransactionTypes.has(type as LendingTransactionTypes) || + lendingTransactionTypes.has(originalType as LendingTransactionTypes); + + const sender = transactionMeta.txParams.from; + if (isStakingTransaction) { - const sender = transactionMeta.txParams.from; this.refreshPooledStakes({ resetCache: true, address: sender }).catch( console.error, ); } + if (isLendingTransaction) { + this.refreshLendingPositions({ address: sender }).catch( + console.error, + ); + } }, ); } - #initializeSDK(networkClientId?: string) { + /** + * Initializes the Earn SDK. + * + * @param networkClientId - The network client id to initialize the Earn SDK for (optional). + */ + async #initializeSDK(networkClientId?: string) { const { selectedNetworkClientId } = networkClientId ? { selectedNetworkClientId: networkClientId } : this.messagingSystem.call('NetworkController:getState'); @@ -320,7 +429,7 @@ export class EarnController extends BaseController< ); if (!networkClient?.provider) { - this.#stakeSDK = null; + this.#earnSDK = null; return; } @@ -328,15 +437,14 @@ export class EarnController extends BaseController< const { chainId } = networkClient.configuration; // Initialize appropriate contracts based on chainId - const config: StakeSdkConfig = { + const config: EarnSdkConfig = { chainId: convertHexToDecimal(chainId), }; try { - this.#stakeSDK = StakeSdk.create(config); - this.#stakeSDK.pooledStakingContract.connectSignerOrProvider(provider); + this.#earnSDK = await EarnSdk.create(provider, config); } catch (error) { - this.#stakeSDK = null; + this.#earnSDK = null; // Only log unexpected errors, not unsupported chain errors if ( !( @@ -344,29 +452,39 @@ export class EarnController extends BaseController< error.message.includes('Unsupported chainId') ) ) { - console.error('Stake SDK initialization failed:', error); + console.error('Earn SDK initialization failed:', error); } } } + /** + * Gets the current account. + * + * @returns The current account. + */ #getCurrentAccount() { return this.messagingSystem.call('AccountsController:getSelectedAccount'); } - #getCurrentChainId(): number { - // const { selectedNetworkClientId } = this.messagingSystem.call( - // 'NetworkController:getState', - // ); - // const { - // configuration: { chainId }, - // } = this.messagingSystem.call( - // 'NetworkController:getNetworkClientById', - // selectedNetworkClientId, - // ); - // return convertHexToDecimal(chainId); - - // TEMP: Until we update our data-fetching and storage solution to not depend on single selected network. - return ChainId.ETHEREUM; + /** + * Gets the current chain id. + * + * @param networkClientId - The network client id to get the chain id for (optional). + * @returns The current chain id in decimal. + */ + #getCurrentChainId(networkClientId?: string): number { + const networkClientIdToUse = + networkClientId ?? + this.messagingSystem.call('NetworkController:getState') + .selectedNetworkClientId; + + const { + configuration: { chainId }, + } = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + networkClientIdToUse, + ); + return convertHexToDecimal(chainId); } /** @@ -377,11 +495,13 @@ export class EarnController extends BaseController< * @param options - Optional arguments * @param [options.resetCache] - Control whether the BE cache should be invalidated (optional). * @param [options.address] - The address to refresh pooled stakes for (optional). + * @param [options.chainId] - The chain id to refresh pooled stakes for (optional). * @returns A promise that resolves when the stakes data has been updated */ async refreshPooledStakes({ resetCache = false, address, + chainId, }: RefreshPooledStakesOptions = {}): Promise { const addressToUse = address ?? this.#getCurrentAccount()?.address; @@ -389,18 +509,24 @@ export class EarnController extends BaseController< return; } - const chainId = this.#getCurrentChainId(); + const chainIdToUse = chainId ?? this.#getCurrentChainId(); const { accounts, exchangeRate } = - await this.#stakingApiService.getPooledStakes( + await this.#earnApiService.pooledStaking.getPooledStakes( [addressToUse], - chainId, + chainIdToUse, resetCache, ); this.update((state) => { - state.pooled_staking.pooledStakes = accounts[0]; - state.pooled_staking.exchangeRate = exchangeRate; + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + pooledStakes: accounts[0], + exchangeRate, + }; }); } @@ -422,12 +548,13 @@ export class EarnController extends BaseController< } const { eligible: isEligible } = - await this.#stakingApiService.getPooledStakingEligibility([ + await this.#earnApiService.pooledStaking.getPooledStakingEligibility([ addressToCheck, ]); this.update((state) => { state.pooled_staking.isEligible = isEligible; + state.lending.isEligible = isEligible; }); } @@ -436,14 +563,22 @@ export class EarnController extends BaseController< * Updates the vault metadata in the controller state including APY, capacity, * fee percentage, total assets, and vault address. * + * @param chainId - The chain id to refresh pooled staking vault metadata for (optional). * @returns A promise that resolves when the vault metadata has been updated */ - async refreshPooledStakingVaultMetadata(): Promise { - const chainId = this.#getCurrentChainId(); - const vaultMetadata = await this.#stakingApiService.getVaultData(chainId); + async refreshPooledStakingVaultMetadata(chainId?: number): Promise { + const chainIdToUse = chainId ?? this.#getCurrentChainId(); + const vaultMetadata = + await this.#earnApiService.pooledStaking.getVaultData(chainIdToUse); this.update((state) => { - state.pooled_staking.vaultMetadata = vaultMetadata; + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + vaultMetadata, + }; }); } @@ -451,23 +586,32 @@ export class EarnController extends BaseController< * Refreshes pooled staking vault daily apys for the current chain. * Updates the pooled staking vault daily apys controller state. * + * @param chainId - The chain id to refresh pooled staking vault daily apys for (optional). * @param days - The number of days to fetch pooled staking vault daily apys for (defaults to 365). * @param order - The order in which to fetch pooled staking vault daily apys. Descending order fetches the latest N days (latest working backwards). Ascending order fetches the oldest N days (oldest working forwards) (defaults to 'desc'). * @returns A promise that resolves when the pooled staking vault daily apys have been updated. */ async refreshPooledStakingVaultDailyApys( + chainId?: number, days = 365, order: 'asc' | 'desc' = 'desc', ): Promise { - const chainId = this.#getCurrentChainId(); - const vaultDailyApys = await this.#stakingApiService.getVaultDailyApys( - chainId, - days, - order, - ); + const chainIdToUse = chainId ?? this.#getCurrentChainId(); + const vaultDailyApys = + await this.#earnApiService.pooledStaking.getVaultDailyApys( + chainIdToUse, + days, + order, + ); this.update((state) => { - state.pooled_staking.vaultDailyApys = vaultDailyApys; + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + vaultDailyApys, + }; }); } @@ -475,15 +619,24 @@ export class EarnController extends BaseController< * Refreshes pooled staking vault apy averages for the current chain. * Updates the pooled staking vault apy averages controller state. * + * @param chainId - The chain id to refresh pooled staking vault apy averages for (optional). * @returns A promise that resolves when the pooled staking vault apy averages have been updated. */ - async refreshPooledStakingVaultApyAverages() { - const chainId = this.#getCurrentChainId(); + async refreshPooledStakingVaultApyAverages(chainId?: number) { + const chainIdToUse = chainId ?? this.#getCurrentChainId(); const vaultApyAverages = - await this.#stakingApiService.getVaultApyAverages(chainId); + await this.#earnApiService.pooledStaking.getVaultApyAverages( + chainIdToUse, + ); this.update((state) => { - state.pooled_staking.vaultApyAverages = vaultApyAverages; + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + vaultApyAverages, + }; }); } @@ -503,31 +656,480 @@ export class EarnController extends BaseController< address, }: RefreshPooledStakingDataOptions = {}): Promise { const errors: Error[] = []; + for (const chainId of this.#supportedPooledStakingChains) { + await Promise.all([ + this.refreshPooledStakes({ resetCache, address, chainId }).catch( + (error) => { + errors.push(error); + }, + ), + this.refreshStakingEligibility({ address }).catch((error) => { + errors.push(error); + }), + this.refreshPooledStakingVaultMetadata(chainId).catch((error) => { + errors.push(error); + }), + this.refreshPooledStakingVaultDailyApys(chainId).catch((error) => { + errors.push(error); + }), + this.refreshPooledStakingVaultApyAverages(chainId).catch((error) => { + errors.push(error); + }), + ]); + } + + if (errors.length > 0) { + throw new Error( + `Failed to refresh some staking data: ${errors + .map((e) => e.message) + .join(', ')}`, + ); + } + } + + /** + * Refreshes the lending markets data for all chains. + * Updates the lending markets in the controller state. + * + * @returns A promise that resolves when the lending markets have been updated + */ + async refreshLendingMarkets(): Promise { + const markets = await this.#earnApiService.lending.getMarkets(); + + this.update((state) => { + state.lending.markets = markets; + }); + } + + /** + * Refreshes the lending positions for the current account. + * Updates the lending positions in the controller state. + * + * @param options - Optional arguments + * @param [options.address] - The address to refresh lending positions for (optional). + * @returns A promise that resolves when the lending positions have been updated + */ + async refreshLendingPositions({ + address, + }: RefreshLendingPositionsOptions = {}): Promise { + const addressToUse = address ?? this.#getCurrentAccount()?.address; + + if (!addressToUse) { + return; + } + + // linter complaining about this not being a promise, but it is + // TODO: figure out why this is not seen as a promise + const positions = await Promise.resolve( + this.#earnApiService.lending.getPositions(addressToUse), + ); + + this.update((state) => { + state.lending.positions = positions.map((position) => ({ + ...position, + marketId: position.market.id, + marketAddress: position.market.address, + protocol: position.market.protocol, + })); + }); + } + + /** + * 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.#getCurrentAccount()?.address; + // 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. + * All errors are collected and thrown as a single error message. + * + * @returns A promise that resolves when all possible data has been updated + * @throws {Error} If any of the refresh operations fail, with concatenated error messages + */ + async refreshLendingData(): Promise { + const errors: Error[] = []; await Promise.all([ - this.refreshPooledStakes({ resetCache, address }).catch((error) => { - errors.push(error); - }), - this.refreshStakingEligibility({ address }).catch((error) => { - errors.push(error); - }), - this.refreshPooledStakingVaultMetadata().catch((error) => { + this.refreshLendingMarkets().catch((error) => { errors.push(error); }), - this.refreshPooledStakingVaultDailyApys().catch((error) => { + this.refreshLendingPositions().catch((error) => { errors.push(error); }), - this.refreshPooledStakingVaultApyAverages().catch((error) => { + this.refreshLendingEligibility().catch((error) => { errors.push(error); }), ]); if (errors.length > 0) { throw new Error( - `Failed to refresh some staking data: ${errors + `Failed to refresh some lending data: ${errors .map((e) => e.message) .join(', ')}`, ); } } + + /** + * Gets the lending position history for the current account. + * + * @param options - Optional arguments + * @param [options.address] - The address to get lending position history for (optional). + * @param [options.chainId] - The chain id to get lending position history for (optional). + * @param [options.positionId] - The position id to get lending position history for. + * @param [options.marketId] - The market id to get lending position history for. + * @param [options.marketAddress] - The market address to get lending position history for. + * @param [options.protocol] - The protocol to get lending position history for. + * @param [options.days] - The number of days to get lending position history for (optional). + * @returns A promise that resolves when the lending position history has been updated + */ + getLendingPositionHistory({ + address, + chainId, + positionId, + marketId, + marketAddress, + protocol, + days = 730, + }: { + address?: string; + chainId?: number; + positionId: string; + marketId: string; + marketAddress: string; + protocol: string; + days?: number; + }) { + const addressToUse = address ?? this.#getCurrentAccount()?.address; + const chainIdToUse = chainId ?? this.#getCurrentChainId(); + + if (!addressToUse) { + return []; + } + + return this.#earnApiService.lending.getPositionHistory( + addressToUse, + chainIdToUse, + protocol, + marketId, + marketAddress, + positionId, + days, + ); + } + + /** + * Gets the lending market daily apys and averages for the current chain. + * + * @param options - Optional arguments + * @param [options.chainId] - The chain id to get lending market daily apys and averages for (optional). + * @param [options.protocol] - The protocol to get lending market daily apys and averages for. + * @param [options.marketId] - The market id to get lending market daily apys and averages for. + * @param [options.days] - The number of days to get lending market daily apys and averages for (optional). + * @returns A promise that resolves when the lending market daily apys and averages have been updated + */ + getLendingMarketDailyApysAndAverages({ + chainId, + protocol, + marketId, + days = 365, + }: { + chainId?: number; + protocol: string; + marketId: string; + days?: number; + }) { + const chainIdToUse = chainId ?? this.#getCurrentChainId(); + return this.#earnApiService.lending.getHistoricMarketApys( + chainIdToUse, + protocol, + marketId, + days, + ); + } + + /** + * Executes a lending deposit transaction. + * + * @param options - The options for the lending deposit transaction. + * @param options.amount - The amount to deposit. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ + async executeLendingDeposit({ + amount, + protocol, + underlyingTokenAddress, + gasOptions, + txOptions, + }: { + amount: string; + protocol: LendingMarket['protocol']; + underlyingTokenAddress: string; + gasOptions: { + gasLimit?: GasLimitParams; + gasBufferPct?: number; + }; + txOptions: Parameters< + typeof TransactionController.prototype.addTransaction + >[1]; + }) { + const address = this.#getCurrentAccount()?.address; + + const transactionData = await this.#earnSDK?.contracts?.lending?.[ + protocol + ]?.[underlyingTokenAddress]?.encodeDepositTransactionData( + amount, + address, + gasOptions, + ); + + if (!transactionData) { + throw new Error('Transaction data not found'); + } + if (!this.#selectedNetworkClientId) { + throw new Error('Selected network client id not found'); + } + + const txHash = await this.#addTransactionFn( + { + ...transactionData, + value: transactionData.value.toString(), + chainId: toHex(this.#getCurrentChainId()), + gasLimit: String(transactionData.gasLimit), + }, + { + ...txOptions, + networkClientId: this.#selectedNetworkClientId, + }, + ); + + return txHash; + } + + /** + * Executes a lending withdraw transaction. + * + * @param options - The options for the lending withdraw transaction. + * @param options.amount - The amount to withdraw. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ + async executeLendingWithdraw({ + amount, + protocol, + underlyingTokenAddress, + gasOptions, + txOptions, + }: { + amount: string; + protocol: LendingMarket['protocol']; + underlyingTokenAddress: string; + gasOptions: { + gasLimit?: GasLimitParams; + gasBufferPct?: number; + }; + txOptions: Parameters< + typeof TransactionController.prototype.addTransaction + >[1]; + }) { + const address = this.#getCurrentAccount()?.address; + + const transactionData = await this.#earnSDK?.contracts?.lending?.[ + protocol + ]?.[underlyingTokenAddress]?.encodeWithdrawTransactionData( + amount, + address, + gasOptions, + ); + + if (!transactionData) { + throw new Error('Transaction data not found'); + } + + if (!this.#selectedNetworkClientId) { + throw new Error('Selected network client id not found'); + } + + const txHash = await this.#addTransactionFn( + { + ...transactionData, + value: transactionData.value.toString(), + chainId: toHex(this.#getCurrentChainId()), + gasLimit: String(transactionData.gasLimit), + }, + { + ...txOptions, + networkClientId: this.#selectedNetworkClientId, + }, + ); + + return txHash; + } + + /** + * Executes a lending token approve transaction. + * + * @param options - The options for the lending token approve transaction. + * @param options.amount - The amount to approve. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ + async executeLendingTokenApprove({ + protocol, + amount, + underlyingTokenAddress, + gasOptions, + txOptions, + }: { + protocol: LendingMarket['protocol']; + amount: string; + underlyingTokenAddress: string; + gasOptions: { + gasLimit?: GasLimitParams; + gasBufferPct?: number; + }; + txOptions: Parameters< + typeof TransactionController.prototype.addTransaction + >[1]; + }) { + const address = this.#getCurrentAccount()?.address; + + const transactionData = await this.#earnSDK?.contracts?.lending?.[ + protocol + ]?.[underlyingTokenAddress]?.encodeUnderlyingTokenApproveTransactionData( + amount, + address, + gasOptions, + ); + + if (!transactionData) { + throw new Error('Transaction data not found'); + } + + if (!this.#selectedNetworkClientId) { + throw new Error('Selected network client id not found'); + } + + const txHash = await this.#addTransactionFn( + { + ...transactionData, + value: transactionData.value.toString(), + chainId: toHex(this.#getCurrentChainId()), + gasLimit: String(transactionData.gasLimit), + }, + { + ...txOptions, + networkClientId: this.#selectedNetworkClientId, + }, + ); + + return txHash; + } + + /** + * Gets the allowance for a lending token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the allowance. + */ + async getLendingTokenAllowance( + protocol: LendingMarket['protocol'], + underlyingTokenAddress: string, + ) { + const address = this.#getCurrentAccount()?.address; + + const allowance = + await this.#earnSDK?.contracts?.lending?.[protocol]?.[ + underlyingTokenAddress + ]?.underlyingTokenAllowance(address); + + return allowance; + } + + /** + * Gets the maximum withdraw amount for a lending token's output token or shares if no output token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the maximum withdraw amount. + */ + async getLendingTokenMaxWithdraw( + protocol: LendingMarket['protocol'], + underlyingTokenAddress: string, + ) { + const address = this.#getCurrentAccount()?.address; + + const maxWithdraw = + await this.#earnSDK?.contracts?.lending?.[protocol]?.[ + underlyingTokenAddress + ]?.maxWithdraw(address); + + return maxWithdraw; + } + + /** + * Gets the maximum deposit amount for a lending token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the maximum deposit amount. + */ + async getLendingTokenMaxDeposit( + protocol: LendingMarket['protocol'], + underlyingTokenAddress: string, + ) { + const address = this.#getCurrentAccount()?.address; + + const maxDeposit = + await this.#earnSDK?.contracts?.lending?.[protocol]?.[ + underlyingTokenAddress + ]?.maxDeposit(address); + + return maxDeposit; + } } diff --git a/packages/earn-controller/src/index.ts b/packages/earn-controller/src/index.ts index 98ed7a4567d..d00a9b03400 100644 --- a/packages/earn-controller/src/index.ts +++ b/packages/earn-controller/src/index.ts @@ -1,7 +1,9 @@ export type { PooledStakingState, - StablecoinLendingState, - StablecoinVault, + LendingState, + LendingMarketWithPosition, + LendingPositionWithMarket, + LendingPositionWithMarketReference, EarnControllerState, EarnControllerGetStateAction, EarnControllerStateChangeEvent, @@ -15,3 +17,18 @@ export { getDefaultEarnControllerState, EarnController, } from './EarnController'; + +export { + selectLendingMarkets, + selectLendingPositions, + selectLendingMarketsWithPosition, + selectLendingPositionsByProtocol, + selectLendingMarketByProtocolAndTokenAddress, + selectLendingMarketForProtocolAndTokenAddress, + selectLendingPositionsByChainId, + selectLendingMarketsByChainId, + selectLendingMarketsByProtocolAndId, + selectLendingMarketForProtocolAndId, + selectLendingPositionsWithMarket, + selectLendingMarketsForChainId, +} from './selectors'; diff --git a/packages/earn-controller/src/selectors.test.ts b/packages/earn-controller/src/selectors.test.ts new file mode 100644 index 00000000000..4ee629e0664 --- /dev/null +++ b/packages/earn-controller/src/selectors.test.ts @@ -0,0 +1,292 @@ +import type { LendingMarket } from '@metamask/stake-sdk'; + +import type { + EarnControllerState, + LendingPositionWithMarket, +} from './EarnController'; +import { + selectLendingMarkets, + selectLendingPositions, + selectLendingMarketsByProtocolAndId, + selectLendingMarketForProtocolAndId, + selectLendingMarketsForChainId, + selectLendingMarketsByChainId, + selectLendingPositionsWithMarket, + selectLendingPositionsByChainId, + selectLendingMarketsWithPosition, + selectLendingPositionsByProtocol, + selectLendingMarketByProtocolAndTokenAddress, + selectLendingMarketForProtocolAndTokenAddress, +} from './selectors'; + +describe('Earn Controller Selectors', () => { + const mockMarket1: LendingMarket = { + id: 'market1', + protocol: 'aave-v3' as LendingMarket['protocol'], + chainId: 1, + name: 'Market 1', + address: '0x123', + tvlUnderlying: '1000', + netSupplyRate: 5, + totalSupplyRate: 5, + underlying: { + address: '0x123', + chainId: 1, + }, + outputToken: { + address: '0x456', + chainId: 1, + }, + rewards: [ + { + token: { + address: '0x789', + chainId: 1, + }, + rate: 0, + }, + ], + }; + + const mockMarket2: LendingMarket = { + id: 'market2', + protocol: 'compound-v3' as LendingMarket['protocol'], + chainId: 2, + name: 'Market 2', + address: '0x456', + tvlUnderlying: '2000', + netSupplyRate: 6, + totalSupplyRate: 6, + underlying: { + address: '0x456', + chainId: 2, + }, + outputToken: { + address: '0xabc', + chainId: 2, + }, + rewards: [ + { + token: { + address: '0xdef', + chainId: 2, + }, + rate: 0, + }, + ], + }; + + const mockPosition1: LendingPositionWithMarket = { + id: 'position1', + chainId: 1, + assets: '100', + marketId: 'market1', + marketAddress: '0x123', + protocol: 'aave-v3' as LendingMarket['protocol'], + market: mockMarket1, + }; + + const mockPosition2: LendingPositionWithMarket = { + id: 'position2', + chainId: 2, + assets: '200', + marketId: 'market2', + marketAddress: '0x456', + protocol: 'compound-v3' as LendingMarket['protocol'], + market: mockMarket2, + }; + + const mockState: EarnControllerState = { + lending: { + markets: [mockMarket1, mockMarket2], + positions: [mockPosition1, mockPosition2], + isEligible: true, + }, + pooled_staking: { + '0': { + pooledStakes: { + account: '', + lifetimeRewards: '0', + assets: '0', + exitRequests: [], + }, + exchangeRate: '1', + vaultMetadata: { + apy: '0', + capacity: '0', + feePercent: 0, + totalAssets: '0', + vaultAddress: '0x0000000000000000000000000000000000000000', + }, + vaultDailyApys: [], + vaultApyAverages: { + oneDay: '0', + oneWeek: '0', + oneMonth: '0', + threeMonths: '0', + sixMonths: '0', + oneYear: '0', + }, + }, + isEligible: false, + }, + lastUpdated: 0, + }; + + describe('selectLendingMarkets', () => { + it('should return all lending markets', () => { + const result = selectLendingMarkets(mockState); + expect(result).toStrictEqual([mockMarket1, mockMarket2]); + }); + }); + + describe('selectLendingPositions', () => { + it('should return all lending positions', () => { + const result = selectLendingPositions(mockState); + expect(result).toStrictEqual([mockPosition1, mockPosition2]); + }); + }); + + describe('selectLendingMarketsByProtocolAndId', () => { + it('should group markets by protocol and id', () => { + const result = selectLendingMarketsByProtocolAndId(mockState); + expect(result).toStrictEqual({ + 'aave-v3': { + market1: mockMarket1, + }, + 'compound-v3': { + market2: mockMarket2, + }, + }); + }); + }); + + describe('selectLendingMarketForProtocolAndId', () => { + it('should return market for given protocol and id', () => { + const result = selectLendingMarketForProtocolAndId( + 'aave-v3', + 'market1', + )(mockState); + expect(result).toStrictEqual(mockMarket1); + const result2 = selectLendingMarketForProtocolAndId( + 'compound-v3', + 'market2', + )(mockState); + expect(result2).toStrictEqual(mockMarket2); + const result3 = selectLendingMarketForProtocolAndId( + 'invalid', + 'invalid', + )(mockState); + expect(result3).toBeUndefined(); + }); + }); + + describe('selectLendingMarketsForChainId', () => { + it('should return markets for given chain id', () => { + const result = selectLendingMarketsForChainId(1)(mockState); + expect(result).toStrictEqual([mockMarket1]); + const result2 = selectLendingMarketsForChainId(2)(mockState); + expect(result2).toStrictEqual([mockMarket2]); + const result3 = selectLendingMarketsForChainId(999)(mockState); + expect(result3).toStrictEqual([]); + }); + }); + + describe('selectLendingMarketsByChainId', () => { + it('should group markets by chain id', () => { + const result = selectLendingMarketsByChainId(mockState); + expect(result).toStrictEqual({ + 1: [mockMarket1], + 2: [mockMarket2], + }); + }); + }); + + describe('selectLendingPositionsWithMarket', () => { + it('should return positions with their associated markets', () => { + const result = selectLendingPositionsWithMarket(mockState); + expect(result).toStrictEqual([mockPosition1, mockPosition2]); + }); + }); + + describe('selectLendingPositionsByChainId', () => { + it('should group positions by chain id', () => { + const result = selectLendingPositionsByChainId(mockState); + expect(result).toStrictEqual({ + 1: [mockPosition1], + 2: [mockPosition2], + }); + }); + }); + + describe('selectLendingMarketsWithPosition', () => { + it('should return markets with their associated positions', () => { + const result = selectLendingMarketsWithPosition(mockState); + expect(result).toHaveLength(2); + expect(result[0]).toStrictEqual({ + ...mockMarket1, + position: { + ...mockPosition1, + market: undefined, + }, + }); + }); + }); + + describe('selectLendingPositionsByProtocol', () => { + it('should group positions by protocol', () => { + const result = selectLendingPositionsByProtocol(mockState); + expect(result).toStrictEqual({ + 'aave-v3': [mockPosition1], + 'compound-v3': [mockPosition2], + }); + }); + }); + + describe('selectLendingMarketByProtocolAndTokenAddress', () => { + it('should group markets by protocol and token address', () => { + const result = selectLendingMarketByProtocolAndTokenAddress(mockState); + expect(result).toStrictEqual({ + 'aave-v3': { + '0x123': { + ...mockMarket1, + position: { + ...mockPosition1, + market: undefined, + }, + }, + }, + 'compound-v3': { + '0x456': { + ...mockMarket2, + position: { + ...mockPosition2, + market: undefined, + }, + }, + }, + }); + }); + }); + + describe('selectLendingMarketForProtocolAndTokenAddress', () => { + it('should return market for given protocol and token address', () => { + const result = selectLendingMarketForProtocolAndTokenAddress( + 'aave-v3', + '0x123', + )(mockState); + expect(result).toStrictEqual({ + ...mockMarket1, + position: { + ...mockPosition1, + market: undefined, + }, + }); + const result2 = selectLendingMarketForProtocolAndTokenAddress( + 'invalid', + 'invalid', + )(mockState); + expect(result2).toBeUndefined(); + }); + }); +}); diff --git a/packages/earn-controller/src/selectors.ts b/packages/earn-controller/src/selectors.ts new file mode 100644 index 00000000000..9810a48de66 --- /dev/null +++ b/packages/earn-controller/src/selectors.ts @@ -0,0 +1,150 @@ +import type { LendingMarket } from '@metamask/stake-sdk'; +import { createSelector, type Selector } from 'reselect'; + +import type { + EarnControllerState, + LendingMarketWithPosition, + LendingPositionWithMarket, +} from './EarnController'; + +export const selectLendingMarkets = (state: EarnControllerState) => + state.lending.markets; + +export const selectLendingPositions = (state: EarnControllerState) => + state.lending.positions; + +export const selectLendingMarketsForChainId = (chainId: number) => + createSelector(selectLendingMarkets, (markets) => + markets.filter((market) => market.chainId === chainId), + ); + +export const selectLendingMarketsByProtocolAndId = createSelector( + selectLendingMarkets, + (markets) => { + return markets.reduce( + (acc, market) => { + acc[market.protocol] = acc[market.protocol] || {}; + acc[market.protocol][market.id] = market; + return acc; + }, + {} as Record>, + ); + }, +); + +export const selectLendingMarketForProtocolAndId = ( + protocol: string, + id: string, +) => + createSelector( + selectLendingMarketsByProtocolAndId, + (marketsByProtocolAndId) => marketsByProtocolAndId?.[protocol]?.[id], + ); + +export const selectLendingMarketsByChainId = createSelector( + selectLendingMarkets, + (markets) => { + return markets.reduce( + (acc, market) => { + acc[market.chainId] = acc[market.chainId] || []; + acc[market.chainId].push(market); + return acc; + }, + {} as Record, + ); + }, +); + +export const selectLendingPositionsWithMarket = createSelector( + selectLendingPositions, + selectLendingMarketsByProtocolAndId, + (positions, marketsByProtocolAndId): LendingPositionWithMarket[] => { + return positions.map((position) => { + return { + ...position, + market: + marketsByProtocolAndId?.[position.protocol]?.[position.marketId], + }; + }); + }, +); + +export const selectLendingPositionsByChainId = createSelector( + selectLendingPositionsWithMarket, + (positionsWithMarket) => { + return positionsWithMarket.reduce( + (acc, position) => { + const chainId = position.market?.chainId; + if (chainId) { + acc[chainId] = acc[chainId] || []; + acc[chainId].push(position); + } + return acc; + }, + {} as Record, + ); + }, +); + +export const selectLendingMarketsWithPosition: Selector< + EarnControllerState, + LendingMarketWithPosition[] +> = createSelector(selectLendingPositionsWithMarket, (positionsWithMarket) => { + return positionsWithMarket + .filter( + ( + position, + ): position is LendingPositionWithMarket & { + market: NonNullable; + } => position.market !== undefined, + ) + .map((positionWithMarket) => { + return { + ...positionWithMarket.market, + position: { + ...positionWithMarket, + market: undefined, + }, + }; + }); +}); + +export const selectLendingPositionsByProtocol = createSelector( + selectLendingPositionsWithMarket, + (positionsWithMarket) => { + return positionsWithMarket.reduce( + (acc, position) => { + acc[position.protocol] = acc[position.protocol] || []; + acc[position.protocol].push(position); + return acc; + }, + {} as Record, + ); + }, +); + +export const selectLendingMarketByProtocolAndTokenAddress = createSelector( + selectLendingMarketsWithPosition, + (marketsWithPosition) => { + return marketsWithPosition.reduce( + (acc, market) => { + if (market.underlying?.address) { + acc[market.protocol] = acc[market.protocol] || {}; + acc[market.protocol][market.underlying.address] = market; + } + return acc; + }, + {} as Record>, + ); + }, +); + +export const selectLendingMarketForProtocolAndTokenAddress = ( + protocol: string, + tokenAddress: string, +) => + createSelector( + selectLendingMarketByProtocolAndTokenAddress, + (marketsByProtocolAndTokenAddress) => + marketsByProtocolAndTokenAddress?.[protocol]?.[tokenAddress], + ); diff --git a/packages/earn-controller/src/types.ts b/packages/earn-controller/src/types.ts index cb281b473e6..48c784db01d 100644 --- a/packages/earn-controller/src/types.ts +++ b/packages/earn-controller/src/types.ts @@ -5,9 +5,19 @@ export type RefreshStakingEligibilityOptions = { export type RefreshPooledStakesOptions = { resetCache?: boolean; address?: string; + chainId?: number; }; export type RefreshPooledStakingDataOptions = { resetCache?: boolean; address?: string; + chainId?: number; +}; + +export type RefreshLendingPositionsOptions = { + address?: string; +}; + +export type RefreshLendingEligibilityOptions = { + address?: string; }; diff --git a/yarn.lock b/yarn.lock index a54425d674b..353913f467f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2998,17 +2998,19 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/earn-controller@workspace:packages/earn-controller" dependencies: + "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/controller-utils": "npm:^11.9.0" "@metamask/network-controller": "npm:^23.5.0" - "@metamask/stake-sdk": "npm:^1.0.0" + "@metamask/stake-sdk": "npm:3.0.0" "@metamask/transaction-controller": "npm:^56.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" + reselect: "npm:^5.1.1" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -4421,10 +4423,10 @@ __metadata: languageName: node linkType: hard -"@metamask/stake-sdk@npm:^1.0.0": - version: 1.0.0 - resolution: "@metamask/stake-sdk@npm:1.0.0" - checksum: 10/96e3fff677aab96e9d26a98c719623ccac59a13e367f2a8fe66174fb00a36fbe32dd6b4664335801a690b2f3744010e6c8e88a4db678742dc6c0d04c0caaf9bb +"@metamask/stake-sdk@npm:3.0.0": + version: 3.0.0 + resolution: "@metamask/stake-sdk@npm:3.0.0" + checksum: 10/d4535c12e84ab616b3c178ac21fd6876b6fd7c682ed8d94ce8fec493694ff1c06970a22fee9942954c00b60a38fbb8f70caa17364927e4967c7e3776175d71ff languageName: node linkType: hard