diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 8e4f6933efe..dfa1b824034 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -49,22 +49,30 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^37.2.0", "@metamask/base-controller": "^9.0.1", + "@metamask/base-data-service": "^0.1.1", + "@metamask/controller-utils": "^11.20.0", "@metamask/eth-money-keyring": "^2.0.0", "@metamask/keyring-api": "^21.6.0", "@metamask/keyring-controller": "^25.2.0", "@metamask/messenger": "^1.1.1", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/network-controller": "^30.0.1", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-utils": "^3.1.0", - "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "nock": "^13.3.1", "ts-jest": "^29.2.5", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index cfc0f01c007..87a38648b99 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -18,3 +18,25 @@ export type { MoneyAccountControllerCreateMoneyAccountAction, MoneyAccountControllerGetMoneyAccountAction, } from './MoneyAccountController-method-action-types'; +export { + MoneyAccountBalanceService, + serviceName as moneyAccountBalanceServiceName, +} from './money-account-balance-service/money-account-balance-service'; +export type { + MoneyAccountBalanceServiceActions, + MoneyAccountBalanceServiceEvents, + MoneyAccountBalanceServiceMessenger, +} from './money-account-balance-service/money-account-balance-service'; +export type { + MoneyAccountBalanceServiceGetMusdBalanceAction, + MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction, + MoneyAccountBalanceServiceGetExchangeRateAction, + MoneyAccountBalanceServiceGetMusdEquivalentValueAction, + MoneyAccountBalanceServiceGetVaultApyAction, +} from './money-account-balance-service/money-account-balance-service-method-action-types'; +export type { + Erc20BalanceResponse, + ExchangeRateResponse, + MusdEquivalentValueResponse, + VaultApyResponse, +} from './money-account-balance-service/response.types'; diff --git a/packages/money-account-controller/src/money-account-balance-service/constants.ts b/packages/money-account-controller/src/money-account-balance-service/constants.ts new file mode 100644 index 00000000000..2ea80a14959 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/constants.ts @@ -0,0 +1,24 @@ +import { Hex } from '@metamask/utils'; + +export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; + +export const VEDA_API_NETWORK_NAMES: Record = { + '0xa4b1': 'arbitrum', +}; + +export const DEFAULT_VEDA_API_NETWORK_NAME = VEDA_API_NETWORK_NAMES['0xa4b1']; + +/** + * Minimal ABI for the Veda Accountant's `getRate()` function (selector 0x679aefce). + * Returns the exchange rate between vault shares (musdSHFvd) and the + * underlying asset (mUSD) as a uint256. + */ +export const ACCOUNTANT_ABI = [ + { + inputs: [], + name: 'getRate', + outputs: [{ internalType: 'uint256', name: 'rate', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/packages/money-account-controller/src/money-account-balance-service/errors.ts b/packages/money-account-controller/src/money-account-balance-service/errors.ts new file mode 100644 index 00000000000..d0de4dc0b28 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/errors.ts @@ -0,0 +1,6 @@ +export class VedaResponseValidationError extends Error { + constructor(message?: string) { + super(message ?? 'Malformed response received from Veda API'); + this.name = 'VedaResponseValidationError'; + } +} diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts new file mode 100644 index 00000000000..f5e74c95a99 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts @@ -0,0 +1,73 @@ +// TODO: This file is supposed to be auto generated. Verify that this generates correctly. The first iteration (seen here) was generated by agent. +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountBalanceService } from './money-account-balance-service'; + +/** + * Fetches the mUSD ERC-20 balance for the given account address via RPC. + * + * @param accountAddress - The Money account's address. + * @returns The mUSD balance as a raw uint256 string. + */ +export type MoneyAccountBalanceServiceGetMusdBalanceAction = { + type: `MoneyAccountBalanceService:getMusdBalance`; + handler: MoneyAccountBalanceService['getMusdBalance']; +}; + +/** + * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * account address via RPC. + * + * @param accountAddress - The Money account's address. + * @returns The musdSHFvd balance as a raw uint256 string. + */ +export type MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction = { + type: `MoneyAccountBalanceService:getMusdSHFvdBalance`; + handler: MoneyAccountBalanceService['getMusdSHFvdBalance']; +}; + +/** + * Fetches the current exchange rate from the Veda Accountant contract via RPC. + * + * @returns The exchange rate as a raw uint256 string. + */ +export type MoneyAccountBalanceServiceGetExchangeRateAction = { + type: `MoneyAccountBalanceService:getExchangeRate`; + handler: MoneyAccountBalanceService['getExchangeRate']; +}; + +/** + * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. + * Internally fetches the musdSHFvd balance and exchange rate (using cached + * values when available), then multiplies them. + * + * @param accountAddress - The Money account's address. + * @returns The musdSHFvd balance, exchange rate, and computed mUSD-equivalent value. + */ +export type MoneyAccountBalanceServiceGetMusdEquivalentValueAction = { + type: `MoneyAccountBalanceService:getMusdEquivalentValue`; + handler: MoneyAccountBalanceService['getMusdEquivalentValue']; +}; + +/** + * Fetches the vault's APY and fee breakdown from the Veda performance REST API. + * + * @returns The 7-day trailing net APY, fees, and per-position breakdown. + */ +export type MoneyAccountBalanceServiceGetVaultApyAction = { + type: `MoneyAccountBalanceService:getVaultApy`; + handler: MoneyAccountBalanceService['getVaultApy']; +}; + +/** + * Union of all MoneyAccountBalanceService action types. + */ +export type MoneyAccountBalanceServiceMethodActions = + | MoneyAccountBalanceServiceGetMusdBalanceAction + | MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction + | MoneyAccountBalanceServiceGetExchangeRateAction + | MoneyAccountBalanceServiceGetMusdEquivalentValueAction + | MoneyAccountBalanceServiceGetVaultApyAction; diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts new file mode 100644 index 00000000000..748c331a5bf --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts @@ -0,0 +1,693 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import { DEFAULT_MAX_RETRIES, HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll as nockCleanAll } from 'nock'; + +import { VedaResponseValidationError } from './errors'; +import type { + MoneyAccountBalanceServiceConfig, + MoneyAccountBalanceServiceMessenger, +} from './money-account-balance-service'; +import { + MoneyAccountBalanceService, + serviceName, +} from './money-account-balance-service'; + +jest.mock('@ethersproject/contracts'); +jest.mock('@ethersproject/providers'); + +const MockContract = Contract as jest.MockedClass; +const MockWeb3Provider = Web3Provider as jest.MockedClass; + +// ============================================================ +// Fixtures +// ============================================================ + +const MOCK_VAULT_ADDRESS = + '0xVaultAddress000000000000000000000000000000' as const; +const MOCK_ACCOUNTANT_ADDRESS = + '0xAccountantAddr000000000000000000000000000' as const; +const MOCK_UNDERLYING_TOKEN_ADDRESS = + '0xMusdAddress0000000000000000000000000000000' as const; +const MOCK_ACCOUNT_ADDRESS = + '0xUserAccount0000000000000000000000000000000' as const; +const MOCK_NETWORK_CLIENT_ID = 'arbitrum-mainnet'; + +const DEFAULT_CONFIG = { + vaultAddress: MOCK_VAULT_ADDRESS, + vaultChainId: '0xa4b1' as const, + accountantAddress: MOCK_ACCOUNTANT_ADDRESS, + underlyingTokenAddress: MOCK_UNDERLYING_TOKEN_ADDRESS, + underlyingTokenDecimals: 6, +}; + +const MOCK_NETWORK_CONFIG = { + chainId: '0xa4b1' as const, + rpcEndpoints: [ + { + networkClientId: MOCK_NETWORK_CLIENT_ID, + url: 'https://arb1.arbitrum.io/rpc', + }, + ], + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + blockExplorerUrls: [], +}; + +// A bare object suffices — Web3Provider and Contract are mocked at the module level. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const MOCK_PROVIDER = {} as any; + +const MOCK_VAULT_APY_RAW_RESPONSE = { + Response: { + aggregation_period: '7 days', + apy: 0.055, + chain_allocation: { arbitrum: 1.0 }, + fees: 0.005, + global_apy_breakdown: { + fee: 0.005, + maturity_apy: 0.03, + real_apy: 0.05, + }, + performance_fees: 0.001, + real_apy_breakdown: [ + { + allocation: 1.0, + apy: 0.055, + apy_net: 0.05, + chain: 'arbitrum', + protocol: 'aave', + }, + ], + timestamp: '2024-01-01T00:00:00Z', + }, +}; + +const MOCK_VAULT_APY_NORMALIZED = { + aggregationPeriod: '7 days', + apy: 0.055, + chainAllocation: { arbitrum: 1.0 }, + fees: 0.005, + globalApyBreakdown: { + fee: 0.005, + maturityApy: 0.03, + realApy: 0.05, + }, + performanceFees: 0.001, + realApyBreakdown: [ + { + allocation: 1.0, + apy: 0.055, + apyNet: 0.05, + chain: 'arbitrum', + protocol: 'aave', + }, + ], + timestamp: '2024-01-01T00:00:00Z', +}; + +// ============================================================ +// Messenger helpers +// ============================================================ + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function createServiceMessenger( + rootMessenger: RootMessenger, +): MoneyAccountBalanceServiceMessenger { + return new Messenger({ + namespace: serviceName, + parent: rootMessenger, + }); +} + +// ============================================================ +// Factory +// ============================================================ + +/** + * Builds the service under test with messenger action stubs for the two + * NetworkController dependencies. + * + * @param args - Optional overrides for the service config and constructor options. + * @param args.config - Partial config merged over {@link DEFAULT_CONFIG}. + * @param args.options - Partial constructor options passed to the service. + * @returns The constructed service together with messenger instances and mock stubs. + */ +function createService({ + config = {}, + options = {}, +}: { + config?: Partial; + options?: Partial< + ConstructorParameters[0] + >; +} = {}): { + service: MoneyAccountBalanceService; + rootMessenger: RootMessenger; + messenger: MoneyAccountBalanceServiceMessenger; + mockGetNetworkConfig: jest.Mock; + mockGetNetworkClient: jest.Mock; +} { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + + const mockGetNetworkConfig = jest.fn().mockReturnValue(MOCK_NETWORK_CONFIG); + const mockGetNetworkClient = jest.fn().mockReturnValue({ + provider: MOCK_PROVIDER, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByChainId', + mockGetNetworkConfig, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mockGetNetworkClient, + ); + + rootMessenger.delegate({ + actions: [ + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:getNetworkClientById', + ], + events: [], + messenger, + }); + + const service = new MoneyAccountBalanceService({ + messenger, + config: { ...DEFAULT_CONFIG, ...config }, + ...options, + }); + + return { + service, + rootMessenger, + messenger, + mockGetNetworkConfig, + mockGetNetworkClient, + }; +} + +/** + * Configures the Contract mock so that `balanceOf` resolves to an object + * whose `.toString()` returns `balance`. + * + * @param balance - The raw uint256 balance string to return. + */ +function mockErc20BalanceOf(balance: string): void { + MockContract.mockImplementation( + () => + ({ + balanceOf: jest.fn().mockResolvedValue({ toString: () => balance }), + }) as unknown as Contract, + ); +} + +/** + * Configures the Contract mock so that `getRate` resolves to an object + * whose `.toString()` returns `rate`. + * + * @param rate - The raw uint256 rate string to return. + */ +function mockAccountantGetRate(rate: string): void { + MockContract.mockImplementation( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => rate }), + }) as unknown as Contract, + ); +} + +// ============================================================ +// Tests +// ============================================================ + +describe('MoneyAccountBalanceService', () => { + beforeEach(() => { + MockWeb3Provider.mockImplementation(() => ({}) as unknown as Web3Provider); + nockCleanAll(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + // ---------------------------------------------------------- + // getMusdBalance + // ---------------------------------------------------------- + + describe('getMusdBalance', () => { + it('returns the mUSD balance for the given address', async () => { + mockErc20BalanceOf('5000000'); + const { service } = createService(); + + const result = await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '5000000' }); + }); + + it('calls balanceOf on the underlying token contract, not the vault', async () => { + mockErc20BalanceOf('5000000'); + const { service } = createService(); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_VAULT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('is also callable via the messenger action', async () => { + mockErc20BalanceOf('5000000'); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:getMusdBalance', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ balance: '5000000' }); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No network configuration found for chain 0xa4b1'); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No provider found for chain 0xa4b1'); + }); + + it('uses the network client at defaultRpcEndpointIndex, not always index 0', async () => { + mockErc20BalanceOf('1000000'); + const { service, mockGetNetworkConfig, mockGetNetworkClient } = + createService(); + mockGetNetworkConfig.mockReturnValue({ + ...MOCK_NETWORK_CONFIG, + rpcEndpoints: [ + { + networkClientId: 'client-at-index-0', + url: 'https://rpc0.example.com', + }, + { + networkClientId: 'client-at-index-1', + url: 'https://rpc1.example.com', + }, + ], + defaultRpcEndpointIndex: 1, + }); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(mockGetNetworkClient).toHaveBeenCalledWith('client-at-index-1'); + expect(mockGetNetworkClient).not.toHaveBeenCalledWith( + 'client-at-index-0', + ); + }); + }); + + // ---------------------------------------------------------- + // getMusdSHFvdBalance + // ---------------------------------------------------------- + + describe('getMusdSHFvdBalance', () => { + it('returns the vault share balance for the given address', async () => { + mockErc20BalanceOf('3000000'); + const { service } = createService(); + + const result = await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '3000000' }); + }); + + it('calls balanceOf on the vault contract, not the underlying token', async () => { + mockErc20BalanceOf('3000000'); + const { service } = createService(); + + await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_VAULT_ADDRESS, + expect.anything(), + expect.anything(), + ); + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + }); + + // ---------------------------------------------------------- + // getExchangeRate + // ---------------------------------------------------------- + + describe('getExchangeRate', () => { + it('returns the exchange rate from the Accountant contract', async () => { + mockAccountantGetRate('1050000'); + const { service } = createService(); + + const result = await service.getExchangeRate(); + + expect(result).toStrictEqual({ rate: '1050000' }); + }); + + it('calls getRate on the accountant contract address', async () => { + mockAccountantGetRate('1050000'); + const { service } = createService(); + + await service.getExchangeRate(); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect(service.getExchangeRate()).rejects.toThrow( + 'No network configuration found for chain 0xa4b1', + ); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect(service.getExchangeRate()).rejects.toThrow( + 'No provider found for chain 0xa4b1', + ); + }); + }); + + // ---------------------------------------------------------- + // getMusdEquivalentValue + // ---------------------------------------------------------- + + describe('getMusdEquivalentValue', () => { + it('returns the vault share balance, exchange rate, and computed mUSD-equivalent value', async () => { + // balance = 2_000_000, rate = 1_100_000, decimals = 6 + // equivalent = (2_000_000 * 1_100_000) / 10^6 = 2_200_000 + MockContract.mockImplementationOnce( + () => + ({ + balanceOf: jest + .fn() + .mockResolvedValue({ toString: () => '2000000' }), + }) as unknown as Contract, + ).mockImplementationOnce( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => '1100000' }), + }) as unknown as Contract, + ); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdSHFvdBalance: '2000000', + exchangeRate: '1100000', + musdEquivalentValue: '2200000', + }); + }); + + it('returns zero musdEquivalentValue when the vault share balance is zero', async () => { + MockContract.mockImplementationOnce( + () => + ({ + balanceOf: jest.fn().mockResolvedValue({ toString: () => '0' }), + }) as unknown as Contract, + ).mockImplementationOnce( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => '1100000' }), + }) as unknown as Contract, + ); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result.musdEquivalentValue).toBe('0'); + }); + + it('truncates (floors) fractional mUSD when the product is not evenly divisible', async () => { + // balance = 1_000_001, rate = 1_000_000, decimals = 6 + // equivalent = (1_000_001 * 1_000_000) / 10^6 = 1_000_001 (exact) + // Check with a value that *would* truncate: balance=3, rate=1_000_000, decimals=6 + // => (3 * 1_000_000) / 1_000_000 = 3 (no truncation needed in this case) + // Real truncation test: balance=7, rate=1_500_000, decimals=6 + // => (7 * 1_500_000) / 1_000_000 = 10_500_000 / 1_000_000 = 10 (BigInt floors) + MockContract.mockImplementationOnce( + () => + ({ + balanceOf: jest.fn().mockResolvedValue({ toString: () => '7' }), + }) as unknown as Contract, + ).mockImplementationOnce( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => '1500000' }), + }) as unknown as Contract, + ); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result.musdEquivalentValue).toBe('10'); + }); + }); + + // ---------------------------------------------------------- + // getVaultApy + // ---------------------------------------------------------- + + describe('getVaultApy', () => { + it('returns the normalized vault APY from the Veda performance API', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual(MOCK_VAULT_APY_NORMALIZED); + }); + + it('uses a custom vedaApiBaseUrl when provided', async () => { + nock('https://custom-veda-api.example.com') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + + const { service } = createService({ + config: { vedaApiBaseUrl: 'https://custom-veda-api.example.com' }, + }); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual(MOCK_VAULT_APY_NORMALIZED); + }); + + it('throws HttpError on a non-200 response', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + new HttpError(500, "Veda performance API failed with status '500'"), + ); + }); + + it('throws VedaResponseValidationError on a malformed response body', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, { unexpected: 'shape' }); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + new VedaResponseValidationError( + 'Malformed response received from Veda performance API', + ), + ); + }); + + it.each([ + { description: 'missing Response key', body: {} }, + { + description: 'missing apy field', + body: { + Response: { ...MOCK_VAULT_APY_RAW_RESPONSE.Response, apy: undefined }, + }, + }, + { + description: 'apy is not a number', + body: { + Response: { ...MOCK_VAULT_APY_RAW_RESPONSE.Response, apy: 'high' }, + }, + }, + { + description: 'missing timestamp field', + body: { + Response: { + ...MOCK_VAULT_APY_RAW_RESPONSE.Response, + timestamp: undefined, + }, + }, + }, + ])( + 'throws VedaResponseValidationError when response is malformed: $description', + async ({ body }) => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, body); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + VedaResponseValidationError, + ); + }, + ); + + it('accepts and normalizes a sparse response where only apy and timestamp are present', async () => { + const sparseResponse = { + Response: { + aggregation_period: '7 days', + apy: 0, + chain_allocation: { arbitrum: 0 }, + fees: 0, + global_apy_breakdown: { fee: 0, maturity_apy: 0, real_apy: 0 }, + maturity_apy_breakdown: [], + real_apy_breakdown: [], + timestamp: 'Fri, 10 Apr 2026 22:05:54 GMT', + }, + }; + + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, sparseResponse); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result.apy).toBe(0); + expect(result.timestamp).toBe('Fri, 10 Apr 2026 22:05:54 GMT'); + expect(result.realApyBreakdown).toStrictEqual([]); + }); + + it('accepts a response that omits all optional fields', async () => { + const minimalResponse = { + Response: { + apy: 0.03, + timestamp: '2026-01-01T00:00:00Z', + }, + }; + + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, minimalResponse); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual({ + aggregationPeriod: undefined, + apy: 0.03, + chainAllocation: undefined, + fees: undefined, + globalApyBreakdown: undefined, + performanceFees: undefined, + realApyBreakdown: undefined, + timestamp: '2026-01-01T00:00:00Z', + }); + }); + + it('does not retry on VedaResponseValidationError', async () => { + // Only one nock scope — if retry happened, the second call would throw a + // different error (nock "no match" instead of VedaResponseValidationError). + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .once() + .reply(200, { unexpected: 'shape' }); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + VedaResponseValidationError, + ); + }); + + it('falls back to the default network name for unknown chain IDs', async () => { + // 0x1 is not in VEDA_API_NETWORK_NAMES, so DEFAULT_VEDA_API_NETWORK_NAME + // ('arbitrum') should be used. Nock matches on exact URL, so if the wrong + // network name were used the request would throw instead of returning data. + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + + const { service } = createService({ + config: { vaultChainId: '0x1' as const }, + }); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual(MOCK_VAULT_APY_NORMALIZED); + }); + }); +}); + +describe('VedaResponseValidationError', () => { + it('uses the default message when constructed with no argument', () => { + const error = new VedaResponseValidationError(); + + expect(error.message).toBe('Malformed response received from Veda API'); + expect(error.name).toBe('VedaResponseValidationError'); + }); +}); diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts new file mode 100644 index 00000000000..de7b73ae8d7 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts @@ -0,0 +1,402 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import { BaseDataService } from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetNetworkConfigurationByChainIdAction, +} from '@metamask/network-controller'; +import { is } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; + +import { + ACCOUNTANT_ABI, + DEFAULT_VEDA_API_NETWORK_NAME, + VEDA_API_NETWORK_NAMES, + VEDA_PERFORMANCE_API_BASE_URL, +} from './constants'; +import { VedaResponseValidationError } from './errors'; +import type { MoneyAccountBalanceServiceMethodActions } from './money-account-balance-service-method-action-types'; +import { normalizeVaultApyResponse } from './requestNormalization'; +import type { + Erc20BalanceResponse, + ExchangeRateResponse, + MusdEquivalentValueResponse, + VaultApyResponse, +} from './response.types'; +import { VaultApyResponseStruct } from './structs'; + +// === GENERAL === + +/** + * The name of the {@link MoneyAccountBalanceService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'MoneyAccountBalanceService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'getMusdBalance', + 'getMusdSHFvdBalance', + 'getExchangeRate', + 'getMusdEquivalentValue', + 'getVaultApy', +] as const; + +/** + * Invalidates cached queries for {@link MoneyAccountBalanceService}. + */ +export type MoneyAccountBalanceServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link MoneyAccountBalanceService} exposes to other consumers. + */ +export type MoneyAccountBalanceServiceActions = + | MoneyAccountBalanceServiceMethodActions + | MoneyAccountBalanceServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link MoneyAccountBalanceService} calls. + */ +type AllowedActions = + | NetworkControllerGetNetworkConfigurationByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; + +/** + * Published when {@link MoneyAccountBalanceService}'s cache is updated. + */ +export type MoneyAccountBalanceServiceCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a key within {@link MoneyAccountBalanceService}'s cache is + * updated. + */ +export type MoneyAccountBalanceServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link MoneyAccountBalanceService} exposes to other consumers. + */ +export type MoneyAccountBalanceServiceEvents = + | MoneyAccountBalanceServiceCacheUpdatedEvent + | MoneyAccountBalanceServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link MoneyAccountBalanceService} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link MoneyAccountBalanceService}. + */ +export type MoneyAccountBalanceServiceMessenger = Messenger< + typeof serviceName, + MoneyAccountBalanceServiceActions | AllowedActions, + MoneyAccountBalanceServiceEvents | AllowedEvents +>; + +export type MoneyAccountBalanceServiceConfig = { + /** The address of the Veda vault (musdSHFvd token contract). */ + vaultAddress: Hex; + /** The chain ID of the Veda vault. */ + vaultChainId: Hex; + /** The address of the Veda Accountant contract. */ + accountantAddress: Hex; + /** The address of the underlying token (mUSD). Must be on the same chain as the vault. */ + underlyingTokenAddress: Hex; + /** + * The decimals of the underlying token. Also determines the precision of + * the Accountant's `getRate()` return value. + */ + underlyingTokenDecimals: number; + /** Base URL for the Veda Seven Seas performance API. Defaults to https://api.sevenseas.capital. */ + vedaApiBaseUrl?: string; +}; + +// === SERVICE DEFINITION === + +/** + * Data service responsible for fetching Money account balances (mUSD and + * musdSHFvd) via on-chain RPC reads, the Veda Accountant exchange rate, and + * the Veda vault APY from the Seven Seas REST API. + * + * All queries are cached via TanStack Query (inherited from + * {@link BaseDataService}) and protected by a service policy that provides + * automatic retries and circuit-breaking. + * + * @example + * + * ```ts + * const service = new MoneyAccountBalanceService({ + * messenger: moneyAccountBalanceServiceMessenger, + * config: { + * vaultAddress: '0x...', + * vaultChainId: '0xa4b1', + * accountantAddress: '0x...', + * underlyingTokenAddress: '0x...', + * underlyingTokenDecimals: 6, + * }, + * }); + * + * const { balance } = await service.getMusdBalance('0xYourMoneyAccount...'); + * ``` + */ +export class MoneyAccountBalanceService extends BaseDataService< + typeof serviceName, + MoneyAccountBalanceServiceMessenger +> { + readonly #config: MoneyAccountBalanceServiceConfig; + + readonly #networkName: string; + + /** + * Constructs a new MoneyAccountBalanceService. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.policyOptions - Options to pass to `createServicePolicy`, + * which is used to wrap each request. + * @param args.config - The configuration for the service. + */ + constructor({ + messenger, + policyOptions = {}, + config, + }: { + messenger: MoneyAccountBalanceServiceMessenger; + config: MoneyAccountBalanceServiceConfig; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ + name: serviceName, + messenger, + policyOptions: { + ...policyOptions, + retryFilterPolicy: handleWhen( + (error) => !(error instanceof VedaResponseValidationError), + ), + }, + }); + + this.#config = { + ...config, + vedaApiBaseUrl: config.vedaApiBaseUrl ?? VEDA_PERFORMANCE_API_BASE_URL, + }; + + this.#networkName = + VEDA_API_NETWORK_NAMES[this.#config.vaultChainId] ?? + DEFAULT_VEDA_API_NETWORK_NAME; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves a Web3Provider for {@link MoneyAccountBalanceServiceConfig.vaultChainId} by looking up the + * network configuration and client via the messenger. + * + * @returns A Web3Provider connected to the vault chain. + * @throws If no network configuration exists for the vault chain, or if the + * resolved network client has no provider. + */ + #getProvider(): Web3Provider { + const config = this.messenger.call( + 'NetworkController:getNetworkConfigurationByChainId', + this.#config.vaultChainId, + ); + + if (!config) { + throw new Error( + `No network configuration found for chain ${this.#config.vaultChainId}`, + ); + } + + const { rpcEndpoints, defaultRpcEndpointIndex } = config; + const { networkClientId } = rpcEndpoints[defaultRpcEndpointIndex]; + + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + if (!networkClient?.provider) { + throw new Error( + `No provider found for chain ${this.#config.vaultChainId}`, + ); + } + + return new Web3Provider(networkClient.provider); + } + + /** + * Fetches the ERC-20 balance for the given contract address and account address via RPC. + * + * @param contractAddress - The address of the ERC-20 contract. + * @param accountAddress - The address of the account. + * @returns The balance as a raw uint256 string. + */ + async #fetchErc20Balance( + contractAddress: Hex, + accountAddress: Hex, + ): Promise { + const provider = this.#getProvider(); + const contract = new Contract(contractAddress, abiERC20, provider); + const balance = await contract.balanceOf(accountAddress); + return balance.toString(); + } + + /** + * Fetches the mUSD ERC-20 balance for the given account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance as a raw uint256 string. + */ + async getMusdBalance(accountAddress: Hex): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdBalance`, accountAddress], + queryFn: async () => { + const balance = await this.#fetchErc20Balance( + this.#config.underlyingTokenAddress, + accountAddress, + ); + return { balance }; + }, + staleTime: inMilliseconds(30, Duration.Second), + }); + } + + /** + * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The musdSHFvd balance as a raw uint256 string. + */ + async getMusdSHFvdBalance( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], + queryFn: async () => { + const balance = await this.#fetchErc20Balance( + this.#config.vaultAddress, + accountAddress, + ); + return { balance }; + }, + staleTime: inMilliseconds(30, Duration.Second), + }); + } + + /** + * Fetches the current exchange rate from the Veda Accountant contract via + * RPC. The rate represents the conversion factor from musdSHFvd shares to + * the underlying mUSD asset. + * + * @returns The exchange rate as a raw uint256 string. + */ + async getExchangeRate(): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getExchangeRate`], + queryFn: async () => { + const provider = this.#getProvider(); + const contract = new Contract( + this.#config.accountantAddress, + ACCOUNTANT_ABI, + provider, + ); + const rate = await contract.getRate(); + return { rate: rate.toString() }; + }, + staleTime: inMilliseconds(30, Duration.Second), + }); + } + + /** + * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. + * Internally fetches the musdSHFvd balance and exchange rate (using cached + * values when available within their staleTime windows), then multiplies + * them. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The musdSHFvd balance, exchange rate, and computed + * mUSD-equivalent value as raw uint256 strings. + */ + async getMusdEquivalentValue( + accountAddress: Hex, + ): Promise { + const [{ balance: musdSHFvdBalance }, { rate: exchangeRate }] = + await Promise.all([ + this.getMusdSHFvdBalance(accountAddress), + this.getExchangeRate(), + ]); + + const balanceBigInt = BigInt(musdSHFvdBalance); + const rateBigInt = BigInt(exchangeRate); + + const musdEquivalentValue = ( + (balanceBigInt * rateBigInt) / + 10n ** BigInt(this.#config.underlyingTokenDecimals) + ).toString(); + + return { + musdSHFvdBalance, + exchangeRate, + musdEquivalentValue, + }; + } + + /** + * Fetches the vault's APY and fee breakdown from the Veda performance REST API. + * + * @returns The normalized vault APY response. + */ + async getVaultApy(): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getVaultApy`], + queryFn: async () => { + const url = new URL( + `/performance/${this.#networkName}/${this.#config.vaultAddress}`, + this.#config.vedaApiBaseUrl, + ); + + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Veda performance API failed with status '${response.status}'`, + ); + } + + const rawResponse = await response.json(); + + // Validate raw response inside queryFn to avoid poisoned cache. + if (!is(rawResponse, VaultApyResponseStruct)) { + throw new VedaResponseValidationError( + 'Malformed response received from Veda performance API', + ); + } + + return normalizeVaultApyResponse(rawResponse); + }, + staleTime: inMilliseconds(5, Duration.Minute), + }); + } +} diff --git a/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts new file mode 100644 index 00000000000..ee3df074012 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts @@ -0,0 +1,40 @@ +import { Infer } from '@metamask/superstruct'; + +import { VaultApyResponse } from './response.types'; +import { VaultApyResponseStruct } from './structs'; + +/** + * Normalizes the raw response from the Veda performance API into the expected + * format. + * + * @param rawResponse - The raw response from the Veda performance API. + * @returns The normalized response. + */ +export function normalizeVaultApyResponse( + rawResponse: Infer, +): VaultApyResponse { + const { Response: response } = rawResponse; + + return { + aggregationPeriod: response.aggregation_period, + apy: response.apy, + chainAllocation: response.chain_allocation, + fees: response.fees, + globalApyBreakdown: response.global_apy_breakdown + ? { + fee: response.global_apy_breakdown.fee, + maturityApy: response.global_apy_breakdown.maturity_apy, + realApy: response.global_apy_breakdown.real_apy, + } + : undefined, + performanceFees: response.performance_fees, + realApyBreakdown: response.real_apy_breakdown?.map((item) => ({ + allocation: item.allocation, + apy: item.apy, + apyNet: item.apy_net, + chain: item.chain, + protocol: item.protocol, + })), + timestamp: response.timestamp, + }; +} diff --git a/packages/money-account-controller/src/money-account-balance-service/response.types.ts b/packages/money-account-controller/src/money-account-balance-service/response.types.ts new file mode 100644 index 00000000000..7f119010b35 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/response.types.ts @@ -0,0 +1,56 @@ +/** + * Response from {@link MoneyAccountBalanceService.#fetchErc20Balance}. + * Balance is a raw uint256 string (no decimal normalization). + */ +export type Erc20BalanceResponse = { + balance: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getExchangeRate}. + * Rate is the raw uint256 string returned by the Accountant's `getRate()`. + */ +export type ExchangeRateResponse = { + rate: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getMusdEquivalentValue}. + * All values are raw uint256 strings. The `musdEquivalentValue` is + * `musdSHFvdBalance * exchangeRate / 10^underlyingTokenDecimals` (= 1e6 for mUSD). + */ +export type MusdEquivalentValueResponse = { + musdSHFvdBalance: string; + exchangeRate: string; + musdEquivalentValue: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getVaultApy}. + * All APY / fee values are decimals (multiply by 100 for percentage). + * + * Only `apy` and `timestamp` are guaranteed to be present — all other fields + * are optional because the Veda API omits them when the vault has no activity. + */ +export type VaultApyResponse = { + aggregationPeriod?: string; // E.g. "7 days" + apy: number; + chainAllocation?: { + [network: string]: number; + }; + fees?: number; + globalApyBreakdown?: { + fee?: number; + maturityApy?: number; + realApy?: number; + }; + performanceFees?: number; + realApyBreakdown?: { + allocation?: number; + apy?: number; + apyNet?: number; + chain?: string; + protocol?: string; + }[]; + timestamp: string; +}; diff --git a/packages/money-account-controller/src/money-account-balance-service/structs.ts b/packages/money-account-controller/src/money-account-balance-service/structs.ts new file mode 100644 index 00000000000..a8bd4f0f082 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/structs.ts @@ -0,0 +1,46 @@ +import { + array, + number, + optional, + record, + string, + type, +} from '@metamask/superstruct'; + +/** + * Superstruct schema for {@link VaultApyResponse}. + * + * Uses `type()` (loose validation) so that unknown fields returned by the + * Veda API do not cause validation failures. + * + * Only `apy` and `timestamp` are required — all other fields are optional + * because the Veda API omits some fields when the vault has no activity. + */ +export const VaultApyResponseStruct = type({ + Response: type({ + aggregation_period: optional(string()), + apy: number(), + chain_allocation: optional(record(string(), number())), + fees: optional(number()), + global_apy_breakdown: optional( + type({ + fee: optional(number()), + maturity_apy: optional(number()), + real_apy: optional(number()), + }), + ), + performance_fees: optional(number()), + real_apy_breakdown: optional( + array( + type({ + allocation: optional(number()), + apy: optional(number()), + apy_net: optional(number()), + chain: optional(string()), + protocol: optional(string()), + }), + ), + ), + timestamp: string(), + }), +}); diff --git a/packages/money-account-controller/tsconfig.build.json b/packages/money-account-controller/tsconfig.build.json index 8fa5f6bf61b..be5597139b8 100644 --- a/packages/money-account-controller/tsconfig.build.json +++ b/packages/money-account-controller/tsconfig.build.json @@ -9,13 +9,20 @@ { "path": "../base-controller/tsconfig.build.json" }, + { + "path": "../base-data-service/tsconfig.build.json" + }, { "path": "../accounts-controller/tsconfig.build.json" }, + { + "path": "../controller-utils/tsconfig.build.json" + }, { "path": "../keyring-controller/tsconfig.build.json" }, - { "path": "../messenger/tsconfig.build.json" } + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/money-account-controller/tsconfig.json b/packages/money-account-controller/tsconfig.json index e1b9b25e4a4..4d2655c31a4 100644 --- a/packages/money-account-controller/tsconfig.json +++ b/packages/money-account-controller/tsconfig.json @@ -5,9 +5,12 @@ }, "references": [ { "path": "../base-controller" }, + { "path": "../base-data-service" }, { "path": "../accounts-controller" }, + { "path": "../controller-utils" }, { "path": "../keyring-controller" }, - { "path": "../messenger" } + { "path": "../messenger" }, + { "path": "../network-controller" } ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 5d0a8876af7..5cadadca844 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4406,20 +4406,28 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-controller@workspace:packages/money-account-controller" dependencies: + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^37.2.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-data-service": "npm:^0.1.1" + "@metamask/controller-utils": "npm:^11.20.0" "@metamask/eth-money-keyring": "npm:^2.0.0" "@metamask/keyring-api": "npm:^21.6.0" "@metamask/keyring-controller": "npm:^25.2.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/messenger": "npm:^1.1.1" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^30.0.1" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" + nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13"