diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index db96f2b4738..b413d971900 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a new `DeFiPositionsController` that maintains an updated list of DeFi positions for EVM accounts ([#5400](https://github.com/MetaMask/core/pull/5400)) + - Export `DeFiPositionsController` + - Export the following types + - `DeFiPositionsControllerState` + - `DeFiPositionsControllerActions` + - `DeFiPositionsControllerEvents` + - `DeFiPositionsControllerGetStateAction` + - `DeFiPositionsControllerStateChangeEvent` + - `DeFiPositionsControllerMessenger` + - `GroupedDeFiPositions` + +### Changed + +- **BREAKING** Add `@metamask/transaction-controller` as a peer dependency at `^54.0.0` ([#5400](https://github.com/MetaMask/core/pull/5400)) + ## [56.0.0] ### Changed diff --git a/packages/assets-controllers/README.md b/packages/assets-controllers/README.md index ea6618a2196..7f7ed3f26af 100644 --- a/packages/assets-controllers/README.md +++ b/packages/assets-controllers/README.md @@ -19,6 +19,7 @@ This package features the following controllers: - [**CollectibleDetectionController**](src/CollectibleDetectionController.ts) keeps a periodically updated list of ERC-721 tokens assigned to the currently selected address. - [**CollectiblesController**](src/CollectiblesController.ts) tracks ERC-721 and ERC-1155 tokens assigned to the currently selected address, using OpenSea to retrieve token information. - [**CurrencyRateController**](src/CurrencyRateController.ts) keeps a periodically updated value of the exchange rate from the currently selected "native" currency to another (handling testnet tokens specially). +- [**DeFiPositionsController**](src/DeFiPositionsController/DeFiPositionsController.ts.ts) keeps a periodically updated value of the DeFi positions for the owner EVM addresses. - [**RatesController**](src/RatesController/RatesController.ts) keeps a periodically updated value for the exchange rates for different cryptocurrencies. The difference between the `RatesController` and `CurrencyRateController` is that the second one is coupled to the `NetworksController` and is EVM specific, whilst the first one can handle different blockchain currencies like BTC and SOL. - [**TokenBalancesController**](src/TokenBalancesController.ts) keeps a periodically updated set of balances for the current set of ERC-20 tokens. - [**TokenDetectionController**](src/TokenDetectionController.ts) keeps a periodically updated list of ERC-20 tokens assigned to the currently selected address. diff --git a/packages/assets-controllers/jest.config.js b/packages/assets-controllers/jest.config.js index a226e79eb7f..cc0e6e01663 100644 --- a/packages/assets-controllers/jest.config.js +++ b/packages/assets-controllers/jest.config.js @@ -14,6 +14,12 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + '/__fixtures__/', + ], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index d6674574079..c9ad5cf795e 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -90,6 +90,7 @@ "@metamask/providers": "^18.1.1", "@metamask/snaps-controllers": "^9.19.0", "@metamask/snaps-sdk": "^6.17.1", + "@metamask/transaction-controller": "^54.1.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", @@ -113,6 +114,7 @@ "@metamask/preferences-controller": "^17.0.0", "@metamask/providers": "^18.1.0", "@metamask/snaps-controllers": "^9.19.0", + "@metamask/transaction-controller": "^54.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts new file mode 100644 index 00000000000..356b4bd4e20 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts @@ -0,0 +1,376 @@ +import { BtcAccountType } from '@metamask/keyring-api'; + +import type { DeFiPositionsControllerMessenger } from './DeFiPositionsController'; +import { + DeFiPositionsController, + getDefaultDefiPositionsControllerState, +} from './DeFiPositionsController'; +import * as fetchPositions from './fetch-positions'; +import * as groupDeFiPositions from './group-defi-positions'; +import { flushPromises } from '../../../../tests/helpers'; +import { createMockInternalAccount } from '../../../accounts-controller/src/tests/mocks'; +import { Messenger } from '../../../base-controller/src/Messenger'; +import type { + ExtractAvailableAction, + ExtractAvailableEvent, +} from '../../../base-controller/tests/helpers'; +import type { + InternalAccount, + TransactionMeta, +} from '../../../transaction-controller/src/types'; + +const OWNER_ACCOUNTS = [ + createMockInternalAccount({ + id: 'mock-id-1', + address: '0x0000000000000000000000000000000000000001', + }), + createMockInternalAccount({ + id: 'mock-id-2', + address: '0x0000000000000000000000000000000000000002', + }), + createMockInternalAccount({ + id: 'mock-id-btc', + type: BtcAccountType.P2wpkh, + }), +]; + +type MainMessenger = Messenger< + ExtractAvailableAction, + ExtractAvailableEvent +>; + +/** + * Sets up the controller with the given configuration + * + * @param config - Configuration for the mock setup + * @param config.isEnabled - Whether the controller is enabled + * @param config.mockFetchPositions - The mock fetch positions function + * @param config.mockGroupDeFiPositions - The mock group positions function + * @returns The controller instance, trigger functions, and spies + */ +function setupController({ + isEnabled, + mockFetchPositions = jest.fn(), + mockGroupDeFiPositions = jest.fn(), +}: { + isEnabled?: () => boolean; + mockFetchPositions?: jest.Mock; + mockGroupDeFiPositions?: jest.Mock; +} = {}) { + const messenger: MainMessenger = new Messenger(); + + const mockListAccounts = jest.fn().mockReturnValue(OWNER_ACCOUNTS); + messenger.registerActionHandler( + 'AccountsController:listAccounts', + mockListAccounts, + ); + + const restrictedMessenger = messenger.getRestricted({ + name: 'DeFiPositionsController', + allowedActions: ['AccountsController:listAccounts'], + allowedEvents: [ + 'KeyringController:unlock', + 'KeyringController:lock', + 'TransactionController:transactionConfirmed', + 'AccountsController:accountAdded', + ], + }); + + const buildPositionsFetcherSpy = jest.spyOn( + fetchPositions, + 'buildPositionFetcher', + ); + + buildPositionsFetcherSpy.mockReturnValue(mockFetchPositions); + + const groupDeFiPositionsSpy = jest.spyOn( + groupDeFiPositions, + 'groupDeFiPositions', + ); + + groupDeFiPositionsSpy.mockImplementation(mockGroupDeFiPositions); + + const controller = new DeFiPositionsController({ + messenger: restrictedMessenger, + isEnabled, + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + + const triggerUnlock = (): void => { + messenger.publish('KeyringController:unlock'); + }; + + const triggerLock = (): void => { + messenger.publish('KeyringController:lock'); + }; + + const triggerTransactionConfirmed = (address: string): void => { + messenger.publish('TransactionController:transactionConfirmed', { + txParams: { + from: address, + }, + } as TransactionMeta); + }; + + const triggerAccountAdded = (account: Partial): void => { + messenger.publish( + 'AccountsController:accountAdded', + account as InternalAccount, + ); + }; + + return { + controller, + triggerUnlock, + triggerLock, + triggerTransactionConfirmed, + triggerAccountAdded, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + }; +} + +describe('DeFiPositionsController', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('sets default state', async () => { + const { controller } = setupController(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + }); + + it('stops polling if the keyring is locked', async () => { + const { controller, triggerLock } = setupController(); + const stopAllPollingSpy = jest.spyOn(controller, 'stopAllPolling'); + + triggerLock(); + + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalled(); + }); + + it('starts polling if the keyring is unlocked', async () => { + const { controller, triggerUnlock } = setupController(); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + + triggerUnlock(); + + await flushPromises(); + + expect(startPollingSpy).toHaveBeenCalled(); + }); + + it('fetches positions for all accounts when polling', async () => { + const mockFetchPositions = jest.fn().mockImplementation((address) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (OWNER_ACCOUNTS[0].address === address) { + return 'mock-fetch-data-1'; + } + + throw new Error('Error fetching positions'); + }); + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const { controller, buildPositionsFetcherSpy, updateSpy } = setupController( + { + mockFetchPositions, + mockGroupDeFiPositions, + }, + ); + + await controller._executePoll(); + + expect(controller.state).toStrictEqual({ + allDeFiPositions: { + [OWNER_ACCOUNTS[0].address]: 'mock-grouped-data-1', + [OWNER_ACCOUNTS[1].address]: null, + }, + }); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).toHaveBeenCalledWith(OWNER_ACCOUNTS[0].address); + expect(mockFetchPositions).toHaveBeenCalledWith(OWNER_ACCOUNTS[1].address); + expect(mockFetchPositions).toHaveBeenCalledTimes(2); + + expect(mockGroupDeFiPositions).toHaveBeenCalledWith('mock-fetch-data-1'); + expect(mockGroupDeFiPositions).toHaveBeenCalledTimes(1); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('does not fetch positions when polling and the controller is disabled', async () => { + const { + controller, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + } = setupController({ + isEnabled: () => false, + }); + + await controller._executePoll(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).not.toHaveBeenCalled(); + + expect(mockGroupDeFiPositions).not.toHaveBeenCalled(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('fetches positions for an account when a transaction is confirmed', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue('mock-fetch-data-1'); + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const { + controller, + triggerTransactionConfirmed, + buildPositionsFetcherSpy, + updateSpy, + } = setupController({ + mockFetchPositions, + mockGroupDeFiPositions, + }); + + triggerTransactionConfirmed(OWNER_ACCOUNTS[0].address); + await flushPromises(); + + expect(controller.state).toStrictEqual({ + allDeFiPositions: { + [OWNER_ACCOUNTS[0].address]: 'mock-grouped-data-1', + }, + }); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).toHaveBeenCalledWith(OWNER_ACCOUNTS[0].address); + expect(mockFetchPositions).toHaveBeenCalledTimes(1); + + expect(mockGroupDeFiPositions).toHaveBeenCalledWith('mock-fetch-data-1'); + expect(mockGroupDeFiPositions).toHaveBeenCalledTimes(1); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('does not fetch positions for an account when a transaction is confirmed and the controller is disabled', async () => { + const { + controller, + triggerTransactionConfirmed, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + } = setupController({ + isEnabled: () => false, + }); + + triggerTransactionConfirmed(OWNER_ACCOUNTS[0].address); + await flushPromises(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).not.toHaveBeenCalled(); + + expect(mockGroupDeFiPositions).not.toHaveBeenCalled(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('fetches positions for an account when a new account is added', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue('mock-fetch-data-1'); + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const { + controller, + triggerAccountAdded, + buildPositionsFetcherSpy, + updateSpy, + } = setupController({ + mockFetchPositions, + mockGroupDeFiPositions, + }); + + const newAccountAddress = '0x0000000000000000000000000000000000000003'; + triggerAccountAdded({ + type: 'eip155:eoa', + address: newAccountAddress, + }); + await flushPromises(); + + expect(controller.state).toStrictEqual({ + allDeFiPositions: { + [newAccountAddress]: 'mock-grouped-data-1', + }, + }); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).toHaveBeenCalledWith(newAccountAddress); + expect(mockFetchPositions).toHaveBeenCalledTimes(1); + + expect(mockGroupDeFiPositions).toHaveBeenCalledWith('mock-fetch-data-1'); + expect(mockGroupDeFiPositions).toHaveBeenCalledTimes(1); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('does not fetch positions for an account when a new account is added and the controller is disabled', async () => { + const { + controller, + triggerAccountAdded, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + } = setupController({ + isEnabled: () => false, + }); + + triggerAccountAdded({ + type: 'eip155:eoa', + address: '0x0000000000000000000000000000000000000003', + }); + await flushPromises(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).not.toHaveBeenCalled(); + + expect(mockGroupDeFiPositions).not.toHaveBeenCalled(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts new file mode 100644 index 00000000000..c9c11f499c7 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts @@ -0,0 +1,248 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerListAccountsAction, +} from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedMessenger, + StateMetadata, +} from '@metamask/base-controller'; +import type { KeyringControllerUnlockEvent } from '@metamask/keyring-controller'; +import type { KeyringControllerLockEvent } from '@metamask/keyring-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import type { DefiPositionResponse } from './fetch-positions'; +import { buildPositionFetcher } from './fetch-positions'; +import { + groupDeFiPositions, + type GroupedDeFiPositions, +} from './group-defi-positions'; +import { reduceInBatchesSerially } from '../assetsUtil'; + +const TEN_MINUTES_IN_MS = 60_000; + +const FETCH_POSITIONS_BATCH_SIZE = 10; + +const controllerName = 'DeFiPositionsController'; + +type GroupedDeFiPositionsPerChain = { + [chain: Hex]: GroupedDeFiPositions; +}; + +export type DeFiPositionsControllerState = { + /** + * Object containing DeFi positions per account and network + */ + allDeFiPositions: { + [accountAddress: string]: GroupedDeFiPositionsPerChain | null; + }; +}; + +const controllerMetadata: StateMetadata = { + allDeFiPositions: { + persist: false, + anonymous: false, + }, +}; + +export const getDefaultDefiPositionsControllerState = + (): DeFiPositionsControllerState => { + return { + allDeFiPositions: {}, + }; + }; + +export type DeFiPositionsControllerActions = + DeFiPositionsControllerGetStateAction; + +export type DeFiPositionsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + DeFiPositionsControllerState +>; + +export type DeFiPositionsControllerEvents = + DeFiPositionsControllerStateChangeEvent; + +export type DeFiPositionsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + DeFiPositionsControllerState + >; + +/** + * The external actions available to the {@link DeFiPositionsController}. + */ +export type AllowedActions = AccountsControllerListAccountsAction; + +/** + * The external events available to the {@link DeFiPositionsController}. + */ +export type AllowedEvents = + | KeyringControllerUnlockEvent + | KeyringControllerLockEvent + | TransactionControllerTransactionConfirmedEvent + | AccountsControllerAccountAddedEvent; + +/** + * The messenger of the {@link DeFiPositionsController}. + */ +export type DeFiPositionsControllerMessenger = RestrictedMessenger< + typeof controllerName, + DeFiPositionsControllerActions | AllowedActions, + DeFiPositionsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * Controller that stores assets and exposes convenience methods + */ +export class DeFiPositionsController extends StaticIntervalPollingController()< + typeof controllerName, + DeFiPositionsControllerState, + DeFiPositionsControllerMessenger +> { + readonly #fetchPositions: ( + accountAddress: string, + ) => Promise; + + readonly #isEnabled: () => boolean; + + /** + * DeFiPositionsController constuctor + * + * @param options - Constructor options. + * @param options.messenger - The controller messenger. + * @param options.isEnabled - Function that returns whether the controller is enabled. (default: () => true) + */ + constructor({ + messenger, + isEnabled = () => true, + }: { + messenger: DeFiPositionsControllerMessenger; + isEnabled?: () => boolean; + }) { + super({ + name: controllerName, + metadata: controllerMetadata, + messenger, + state: getDefaultDefiPositionsControllerState(), + }); + + this.setIntervalLength(TEN_MINUTES_IN_MS); + + this.#fetchPositions = buildPositionFetcher(); + this.#isEnabled = isEnabled; + + this.messagingSystem.subscribe('KeyringController:unlock', () => { + this.startPolling(null); + }); + + this.messagingSystem.subscribe('KeyringController:lock', () => { + this.stopAllPolling(); + }); + + this.messagingSystem.subscribe( + 'TransactionController:transactionConfirmed', + async (transactionMeta) => { + if (!this.#isEnabled()) { + return; + } + + await this.#updateAccountPositions(transactionMeta.txParams.from); + }, + ); + + this.messagingSystem.subscribe( + 'AccountsController:accountAdded', + async (account) => { + if (!this.#isEnabled() || !account.type.startsWith('eip155:')) { + return; + } + + await this.#updateAccountPositions(account.address); + }, + ); + } + + async _executePoll(): Promise { + if (!this.#isEnabled()) { + return; + } + + const accounts = this.messagingSystem.call( + 'AccountsController:listAccounts', + ); + + const initialResult: { + accountAddress: string; + positions: GroupedDeFiPositionsPerChain | null; + }[] = []; + + const results = await reduceInBatchesSerially({ + initialResult, + values: accounts, + batchSize: FETCH_POSITIONS_BATCH_SIZE, + eachBatch: async (workingResult, batch) => { + const batchResults = ( + await Promise.all( + batch.map(async ({ address: accountAddress, type }) => { + if (type.startsWith('eip155:')) { + const positions = + await this.#fetchAccountPositions(accountAddress); + + return { + accountAddress, + positions, + }; + } + + return undefined; + }), + ) + ).filter(Boolean) as { + accountAddress: string; + positions: GroupedDeFiPositionsPerChain | null; + }[]; + + return [...workingResult, ...batchResults]; + }, + }); + + const allDefiPositions = results.reduce( + (acc, { accountAddress, positions }) => { + acc[accountAddress] = positions; + return acc; + }, + {} as DeFiPositionsControllerState['allDeFiPositions'], + ); + + this.update((state) => { + state.allDeFiPositions = allDefiPositions; + }); + } + + async #updateAccountPositions(accountAddress: string): Promise { + const accountPositionsPerChain = + await this.#fetchAccountPositions(accountAddress); + + this.update((state) => { + state.allDeFiPositions[accountAddress] = accountPositionsPerChain; + }); + } + + async #fetchAccountPositions( + accountAddress: string, + ): Promise { + try { + const defiPositionsResponse = await this.#fetchPositions(accountAddress); + + return groupDeFiPositions(defiPositionsResponse); + } catch { + return null; + } + } +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-responses.ts b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-responses.ts new file mode 100644 index 00000000000..f955f877cdd --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-responses.ts @@ -0,0 +1,625 @@ +import type { DefiPositionResponse } from '../fetch-positions'; + +/** + * Entries are from different chains + */ +export const MOCK_DEFI_RESPONSE_MULTI_CHAIN: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '5000000000000000000', + balance: 5, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '5000000000000000000', + balance: 5, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + ], + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 8453, + productId: 'a-token', + chainName: 'base', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '5000000000000000000', + balance: 5, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '5000000000000000000', + balance: 5, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + ], + }, +]; + +/** + * The first entry is a failed entry + */ +export const MOCK_DEFI_RESPONSE_FAILED_ENTRY: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 VariableDebtToken', + description: 'Aave v3 defi adapter for variable interest-accruing token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'borrow', + chainId: 1, + productId: 'variable-debt-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: false, + error: { + message: 'Failed to fetch positions', + }, + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '5000000000000000000', + balance: 5, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '5000000000000000000', + balance: 5, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + ], + }, +]; + +/** + * The second entry has no price + */ +export const MOCK_DEFI_RESPONSE_NO_PRICES: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: undefined, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + }, +]; + +/** + * The second entry is a borrow position + */ +export const MOCK_DEFI_RESPONSE_BORROW: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: 500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 VariableDebtToken', + description: 'Aave v3 defi adapter for variable interest-accruing token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'borrow', + chainId: 1, + productId: 'variable-debt-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', + name: 'Aave Ethereum Variable Debt USDT', + symbol: 'variableDebtEthUSDT', + decimals: 6, + balanceRaw: '1000000000', + type: 'protocol', + tokens: [ + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + type: 'underlying', + balanceRaw: '1000000000', + balance: 1000, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + balance: 1000, + }, + ], + }, +]; + +/** + * Complex mock with multiple chains, failed entries, borrow positions, etc. + */ +export const MOCK_DEFI_RESPONSE_COMPLEX: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: 500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 VariableDebtToken', + description: 'Aave v3 defi adapter for variable interest-accruing token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'borrow', + chainId: 1, + productId: 'variable-debt-token', + chainName: 'ethereum', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', + name: 'Aave Ethereum Variable Debt USDT', + symbol: 'variableDebtEthUSDT', + decimals: 6, + balanceRaw: '1000000000', + type: 'protocol', + tokens: [ + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + type: 'underlying', + balanceRaw: '1000000000', + balance: 1000, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + balance: 1000, + }, + ], + }, + { + protocolId: 'lido', + name: 'Lido wstEth', + description: 'Lido defi adapter for wstEth', + siteUrl: 'https://stake.lido.fi/wrap', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + positionType: 'stake', + chainId: 1, + productId: 'wst-eth', + chainName: 'ethereum', + success: true, + tokens: [ + { + address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + name: 'Wrapped liquid staked Ether 2.0', + symbol: 'wstETH', + decimals: 18, + balanceRaw: '800000000000000000000', + balance: 800, + type: 'protocol', + tokens: [ + { + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + name: 'Liquid staked Ether 2.0', + symbol: 'stETH', + decimals: 18, + type: 'underlying', + balanceRaw: '1000000000000000000', + balance: 10, + price: 2000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + tokens: [ + { + address: '0x0000000000000000000000000000000000000000', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + type: 'underlying', + balanceRaw: '1000000000000000000', + balance: 10, + price: 2000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/info/logo.png', + }, + ], + }, + ], + }, + ], + }, + { + protocolId: 'uniswap-v3', + name: 'UniswapV3', + description: 'UniswapV3 defi adapter', + siteUrl: 'https://uniswap.org/', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png', + positionType: 'supply', + chainId: 8453, + productId: 'pool', + chainName: 'base', + success: true, + tokens: [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940758', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '1000000000000000000', + balance: 1, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '100000000000000000000', + type: 'underlying', + balance: 100, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '10000000000000000000', + type: 'underlying-claimable', + balance: 10, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '500000000', + type: 'underlying', + balance: 500, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940760', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '2000000000000000000', + balance: 2, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '90000000000000000000000', + type: 'underlying', + balance: 90000, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '50000000000000000000', + type: 'underlying-claimable', + balance: 50, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '60000000', + type: 'underlying', + balance: 60, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + }, +]; diff --git a/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.test.ts b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.test.ts new file mode 100644 index 00000000000..e9228a070a8 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.test.ts @@ -0,0 +1,67 @@ +import nock from 'nock'; + +import { + DEFI_POSITIONS_API_URL, + buildPositionFetcher, +} from './fetch-positions'; + +describe('fetchPositions', () => { + const mockAccountAddress = '0x1234567890123456789012345678901234567890'; + + const mockResponse = { + data: [ + { + chainId: 1, + chainName: 'Ethereum Mainnet', + protocolId: 'aave-v3', + productId: 'lending', + name: 'Aave V3', + description: 'Lending protocol', + iconUrl: 'https://example.com/icon.png', + siteUrl: 'https://example.com', + positionType: 'supply', + success: true, + tokens: [ + { + type: 'protocol', + address: '0xtoken', + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + balanceRaw: '1000000000000000000', + balance: 1, + price: 100, + iconUrl: 'https://example.com/token.png', + }, + ], + }, + ], + }; + + it('handles successful responses', async () => { + const scope = nock(DEFI_POSITIONS_API_URL) + .get(`/positions/${mockAccountAddress}`) + .reply(200, mockResponse); + + const fetchPositions = buildPositionFetcher(); + + const result = await fetchPositions(mockAccountAddress); + + expect(result).toStrictEqual(mockResponse.data); + expect(scope.isDone()).toBe(true); + }); + + it('handles non-200 responses', async () => { + const scope = nock(DEFI_POSITIONS_API_URL) + .get(`/positions/${mockAccountAddress}`) + .reply(400); + + const fetchPositions = buildPositionFetcher(); + + await expect(fetchPositions(mockAccountAddress)).rejects.toThrow( + 'Unable to fetch defi positions - HTTP 400', + ); + + expect(scope.isDone()).toBe(true); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.ts b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.ts new file mode 100644 index 00000000000..d384bb128f2 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.ts @@ -0,0 +1,80 @@ +export type DefiPositionResponse = AdapterResponse<{ + tokens: ProtocolToken[]; +}>; + +type ProtocolDetails = { + chainId: number; + protocolId: string; + productId: string; + name: string; + description: string; + iconUrl: string; + siteUrl: string; + positionType: PositionType; + metadata?: { + groupPositions?: boolean; + }; +}; + +type AdapterResponse = + | (ProtocolDetails & { + chainName: string; + } & ( + | (ProtocolResponse & { success: true }) + | (AdapterErrorResponse & { success: false }) + )) + | (AdapterErrorResponse & { success: false }); + +type AdapterErrorResponse = { + error: { + message: string; + }; +}; + +export type PositionType = 'supply' | 'borrow' | 'stake' | 'reward'; + +export type ProtocolToken = Balance & { + type: 'protocol'; + tokenId?: string; +}; + +export type Underlying = Balance & { + type: 'underlying' | 'underlying-claimable'; + iconUrl: string; +}; + +export type Balance = { + address: string; + name: string; + symbol: string; + decimals: number; + balanceRaw: string; + balance: number; + price?: number; + tokens?: Underlying[]; +}; + +// TODO: Update with prod API URL when available +export const DEFI_POSITIONS_API_URL = + 'https://defiadapters.dev-api.cx.metamask.io'; + +/** + * Builds a function that fetches DeFi positions for a given account address + * + * @returns A function that fetches DeFi positions for a given account address + */ +export function buildPositionFetcher() { + return async (accountAddress: string): Promise => { + const defiPositionsResponse = await fetch( + `${DEFI_POSITIONS_API_URL}/positions/${accountAddress}`, + ); + + if (defiPositionsResponse.status !== 200) { + throw new Error( + `Unable to fetch defi positions - HTTP ${defiPositionsResponse.status}`, + ); + } + + return (await defiPositionsResponse.json()).data; + }; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts new file mode 100644 index 00000000000..2246c09d322 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts @@ -0,0 +1,365 @@ +import type { Hex } from '@metamask/utils'; +import assert from 'assert'; + +import { + MOCK_DEFI_RESPONSE_BORROW, + MOCK_DEFI_RESPONSE_COMPLEX, + MOCK_DEFI_RESPONSE_FAILED_ENTRY, + MOCK_DEFI_RESPONSE_MULTI_CHAIN, + MOCK_DEFI_RESPONSE_NO_PRICES, +} from './__fixtures__/mock-responses'; +import type { GroupedDeFiPositions } from './group-defi-positions'; +import { groupDeFiPositions } from './group-defi-positions'; + +describe('groupDeFiPositions', () => { + it('groups multiple chains', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_MULTI_CHAIN); + + expect(Object.keys(result)).toHaveLength(2); + expect(Object.keys(result)[0]).toBe('0x1'); + expect(Object.keys(result)[1]).toBe('0x2105'); + }); + + it('does not display failed entries', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_FAILED_ENTRY); + + const protocolResults = result['0x1'].protocols['aave-v3']; + expect(protocolResults.positionTypes.supply).toBeDefined(); + expect(protocolResults.positionTypes.borrow).toBeUndefined(); + }); + + it('handles results with no prices and displays them', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_NO_PRICES); + + const supplyResults = + result['0x1'].protocols['aave-v3'].positionTypes.supply; + expect(supplyResults).toBeDefined(); + assert(supplyResults); + expect(Object.values(supplyResults.positions)).toHaveLength(1); + expect(Object.values(supplyResults.positions[0])).toHaveLength(2); + expect(supplyResults.aggregatedMarketValue).toBe(40); + }); + + it('substracts borrow positions from total market value', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_BORROW); + + const protocolResults = result['0x1'].protocols['aave-v3']; + assert(protocolResults.positionTypes.supply); + assert(protocolResults.positionTypes.borrow); + expect(protocolResults.positionTypes.supply.aggregatedMarketValue).toBe( + 1540, + ); + expect(protocolResults.positionTypes.borrow.aggregatedMarketValue).toBe( + 1000, + ); + expect(protocolResults.aggregatedMarketValue).toBe(540); + }); + + it('verifies that the resulting object is valid', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_COMPLEX); + + const expectedResult: { [key: Hex]: GroupedDeFiPositions } = { + '0x1': { + aggregatedMarketValue: 20540, + protocols: { + 'aave-v3': { + protocolDetails: { + name: 'AaveV3', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + }, + aggregatedMarketValue: 540, + positionTypes: { + supply: { + aggregatedMarketValue: 1540, + positions: [ + [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + marketValue: 40, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + marketValue: 40, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + marketValue: 1500, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: 500, + marketValue: 1500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + ], + }, + borrow: { + aggregatedMarketValue: 1000, + positions: [ + [ + { + address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', + name: 'Aave Ethereum Variable Debt USDT', + symbol: 'variableDebtEthUSDT', + decimals: 6, + balanceRaw: '1000000000', + marketValue: 1000, + type: 'protocol', + tokens: [ + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + type: 'underlying', + balanceRaw: '1000000000', + balance: 1000, + price: 1, + marketValue: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + balance: 1000, + }, + ], + ], + }, + }, + }, + lido: { + protocolDetails: { + name: 'Lido', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + }, + aggregatedMarketValue: 20000, + positionTypes: { + stake: { + aggregatedMarketValue: 20000, + positions: [ + [ + { + address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + name: 'Wrapped liquid staked Ether 2.0', + symbol: 'wstETH', + decimals: 18, + balanceRaw: '800000000000000000000', + balance: 800, + marketValue: 20000, + type: 'protocol', + tokens: [ + { + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + name: 'Liquid staked Ether 2.0', + symbol: 'stETH', + decimals: 18, + type: 'underlying', + balanceRaw: '1000000000000000000', + balance: 10, + price: 2000, + marketValue: 20000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + }, + ], + }, + ], + ], + }, + }, + }, + }, + }, + '0x2105': { + aggregatedMarketValue: 9580, + protocols: { + 'uniswap-v3': { + protocolDetails: { + name: 'UniswapV3', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png', + }, + aggregatedMarketValue: 9580, + positionTypes: { + supply: { + aggregatedMarketValue: 9580, + positions: [ + [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940758', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '1000000000000000000', + balance: 1, + marketValue: 513, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '100000000000000000000', + type: 'underlying', + balance: 100, + price: 0.1, + marketValue: 10, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '10000000000000000000', + type: 'underlying-claimable', + balance: 10, + price: 0.1, + marketValue: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '500000000', + type: 'underlying', + balance: 500, + price: 1, + marketValue: 500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + marketValue: 2, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940760', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '2000000000000000000', + balance: 2, + marketValue: 9067, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '90000000000000000000000', + type: 'underlying', + balance: 90000, + price: 0.1, + marketValue: 9000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '50000000000000000000', + type: 'underlying-claimable', + balance: 50, + price: 0.1, + marketValue: 5, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '60000000', + type: 'underlying', + balance: 60, + price: 1, + marketValue: 60, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + marketValue: 2, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + ], + }, + }, + }, + }, + }, + }; + + expect(result).toStrictEqual(expectedResult); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts new file mode 100644 index 00000000000..df0f04f10f4 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts @@ -0,0 +1,156 @@ +import { toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; +import { upperFirst, camelCase } from 'lodash'; + +import type { + DefiPositionResponse, + PositionType, + ProtocolToken, + Underlying, + Balance, +} from './fetch-positions'; + +export type GroupedDeFiPositions = { + aggregatedMarketValue: number; + protocols: { + [protocolId: string]: { + protocolDetails: { name: string; iconUrl: string }; + aggregatedMarketValue: number; + positionTypes: { + [key in PositionType]?: { + aggregatedMarketValue: number; + positions: ProtocolTokenWithMarketValue[][]; + }; + }; + }; + }; +}; + +export type ProtocolTokenWithMarketValue = Omit & { + marketValue?: number; + tokens: UnderlyingWithMarketValue[]; +}; + +export type UnderlyingWithMarketValue = Omit & { + marketValue?: number; +}; + +/** + * + * @param defiPositionsResponse - The response from the defi positions API + * @returns The grouped positions that get assigned to the state + */ +export function groupDeFiPositions( + defiPositionsResponse: DefiPositionResponse[], +): { + [key: Hex]: GroupedDeFiPositions; +} { + const groupedDeFiPositions: { [key: Hex]: GroupedDeFiPositions } = {}; + + for (const position of defiPositionsResponse) { + if (!position.success) { + continue; + } + + const { chainId, protocolId, iconUrl, positionType } = position; + + const chain = toHex(chainId); + + if (!groupedDeFiPositions[chain]) { + groupedDeFiPositions[chain] = { + aggregatedMarketValue: 0, + protocols: {}, + }; + } + + const chainData = groupedDeFiPositions[chain]; + + if (!chainData.protocols[protocolId]) { + chainData.protocols[protocolId] = { + protocolDetails: { + name: upperFirst(camelCase(protocolId)), + iconUrl, + }, + aggregatedMarketValue: 0, + positionTypes: {}, + }; + } + + const protocolData = chainData.protocols[protocolId]; + + let positionTypeData = protocolData.positionTypes[positionType]; + if (!positionTypeData) { + positionTypeData = { + aggregatedMarketValue: 0, + positions: [], + }; + protocolData.positionTypes[positionType] = positionTypeData; + } + + for (const protocolToken of position.tokens) { + const token = processToken(protocolToken) as ProtocolTokenWithMarketValue; + + // If groupPositions is true, we group all positions of the same type + if (position.metadata?.groupPositions) { + if (positionTypeData.positions.length === 0) { + positionTypeData.positions.push([token]); + } else { + positionTypeData.positions[0].push(token); + } + } else { + positionTypeData.positions.push([token]); + } + + if (token.marketValue) { + const multiplier = position.positionType === 'borrow' ? -1 : 1; + + positionTypeData.aggregatedMarketValue += token.marketValue; + protocolData.aggregatedMarketValue += token.marketValue * multiplier; + chainData.aggregatedMarketValue += token.marketValue * multiplier; + } + } + } + + return groupedDeFiPositions; +} + +/** + * + * @param tokenBalance - The token balance that is going to be processed + * @returns The processed token balance + */ +function processToken( + tokenBalance: T, +): T & { + marketValue?: number; + tokens?: UnderlyingWithMarketValue[]; +} { + if (!tokenBalance.tokens) { + return { + ...tokenBalance, + marketValue: tokenBalance.price + ? tokenBalance.balance * tokenBalance.price + : undefined, + }; + } + + const processedTokens = tokenBalance.tokens.map((t) => { + const { tokens, ...tokenWithoutUnderlyings } = processToken(t); + + return tokenWithoutUnderlyings; + }); + + const marketValue = processedTokens.reduce( + (acc, t) => + acc === undefined || t.marketValue === undefined + ? undefined + : acc + t.marketValue, + 0 as number | undefined, + ); + + return { + ...tokenBalance, + marketValue, + tokens: processedTokens, + }; +} diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 61071436cc5..d2fd8c89a62 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -195,3 +195,13 @@ export type { TokenSearchDiscoveryDataControllerActions, TokenSearchDiscoveryDataControllerMessenger, } from './TokenSearchDiscoveryDataController'; +export { DeFiPositionsController } from './DeFiPositionsController/DeFiPositionsController'; +export type { + DeFiPositionsControllerState, + DeFiPositionsControllerActions, + DeFiPositionsControllerEvents, + DeFiPositionsControllerGetStateAction, + DeFiPositionsControllerStateChangeEvent, + DeFiPositionsControllerMessenger, +} from './DeFiPositionsController/DeFiPositionsController'; +export type { GroupedDeFiPositions } from './DeFiPositionsController/group-defi-positions'; diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index 5e74c070fc5..c7c7bc20350 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -14,7 +14,9 @@ { "path": "../network-controller/tsconfig.build.json" }, { "path": "../preferences-controller/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" }, - { "path": "../permission-controller/tsconfig.build.json" } + { "path": "../permission-controller/tsconfig.build.json" }, + { "path": "../transaction-controller/tsconfig.build.json" } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src"], + "exclude": ["**/*.test.ts", "**/__fixtures__/"] } diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index d86b6d1a374..578f600e201 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -13,7 +13,8 @@ { "path": "../network-controller" }, { "path": "../preferences-controller" }, { "path": "../polling-controller" }, - { "path": "../permission-controller" } + { "path": "../permission-controller" }, + { "path": "../transaction-controller" } ], "include": ["../../types", "./src", "../../tests"] } diff --git a/yarn.lock b/yarn.lock index 6d58c4ed1a9..062b78f9bbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,6 +2589,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^9.19.0" "@metamask/snaps-sdk": "npm:^6.17.1" "@metamask/snaps-utils": "npm:^8.10.0" + "@metamask/transaction-controller": "npm:^54.1.0" "@metamask/utils": "npm:^11.2.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" @@ -2622,6 +2623,7 @@ __metadata: "@metamask/preferences-controller": ^17.0.0 "@metamask/providers": ^18.1.0 "@metamask/snaps-controllers": ^9.19.0 + "@metamask/transaction-controller": ^54.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft