diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7da370ce3fe..91c61401f6c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -39,6 +39,7 @@ ## Earn Team /packages/earn-controller @MetaMask/earn +/packages/money-account-balance-service @MetaMask/earn ## Social AI Team /packages/ai-controllers @MetaMask/social-ai @@ -146,6 +147,8 @@ /packages/delegation-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform /packages/earn-controller/package.json @MetaMask/earn @MetaMask/core-platform /packages/earn-controller/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform /packages/eip-5792-middleware/package.json @MetaMask/wallet-integrations @MetaMask/core-platform /packages/eip-5792-middleware/CHANGELOG.md @MetaMask/wallet-integrations @MetaMask/core-platform /packages/eip1193-permission-middleware/package.json @MetaMask/wallet-integrations @MetaMask/core-platform diff --git a/README.md b/README.md index 2d6154989be..315b9c53af0 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/messenger`](packages/messenger) - [`@metamask/messenger-cli`](packages/messenger-cli) +- [`@metamask/money-account-balance-service`](packages/money-account-balance-service) - [`@metamask/money-account-controller`](packages/money-account-controller) - [`@metamask/multichain-account-service`](packages/multichain-account-service) - [`@metamask/multichain-api-middleware`](packages/multichain-api-middleware) @@ -151,6 +152,7 @@ linkStyle default opacity:0.5 message_manager(["@metamask/message-manager"]); messenger(["@metamask/messenger"]); messenger_cli(["@metamask/messenger-cli"]); + money_account_balance_service(["@metamask/money-account-balance-service"]); money_account_controller(["@metamask/money-account-controller"]); multichain_account_service(["@metamask/multichain-account-service"]); multichain_api_middleware(["@metamask/multichain-api-middleware"]); @@ -350,6 +352,10 @@ linkStyle default opacity:0.5 message_manager --> base_controller; message_manager --> controller_utils; message_manager --> messenger; + money_account_balance_service --> base_data_service; + money_account_balance_service --> controller_utils; + money_account_balance_service --> messenger; + money_account_balance_service --> network_controller; money_account_controller --> accounts_controller; money_account_controller --> base_controller; money_account_controller --> keyring_controller; diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md new file mode 100644 index 00000000000..5b58a9b20e5 --- /dev/null +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add `MoneyAccountBalanceService` data service ([#8428](https://github.com/MetaMask/core/pull/8428)) + - Fetch mUSD ERC-20 balance via RPC (`getMusdBalance`) + - Fetch musdSHFvd vault share balance via RPC (`getMusdSHFvdBalance`) + - Fetch Veda Accountant exchange rate via RPC (`getExchangeRate`) + - Compute mUSD-equivalent value of vault share holdings (`getMusdEquivalentValue`) + - Fetch vault APY from the Veda performance REST API (`getVaultApy`) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-balance-service/LICENSE b/packages/money-account-balance-service/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-balance-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/money-account-balance-service/README.md b/packages/money-account-balance-service/README.md new file mode 100644 index 00000000000..636f8b2412a --- /dev/null +++ b/packages/money-account-balance-service/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-balance-service` + +Data service for fetching Money account balances via on-chain RPC reads, the Veda Accountant exchange rate, and the Veda vault APY from Veda's REST API. + +## Installation + +`yarn add @metamask/money-account-balance-service` + +or + +`npm install @metamask/money-account-balance-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core/blob/main/README.md). diff --git a/packages/money-account-balance-service/jest.config.js b/packages/money-account-balance-service/jest.config.js new file mode 100644 index 00000000000..c17efa251af --- /dev/null +++ b/packages/money-account-balance-service/jest.config.js @@ -0,0 +1,24 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + displayName, + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/money-account-balance-service/package.json b/packages/money-account-balance-service/package.json new file mode 100644 index 00000000000..cf2b5faed6d --- /dev/null +++ b/packages/money-account-balance-service/package.json @@ -0,0 +1,82 @@ +{ + "name": "@metamask/money-account-balance-service", + "version": "0.0.0", + "description": "Data service for fetching Money account balances, exchange rates, and vault APY", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-balance-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-balance-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-balance-service", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@metamask/base-data-service": "^0.1.1", + "@metamask/controller-utils": "^11.20.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" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.0.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", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts new file mode 100644 index 00000000000..2ea80a14959 --- /dev/null +++ b/packages/money-account-balance-service/src/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-balance-service/src/errors.ts b/packages/money-account-balance-service/src/errors.ts new file mode 100644 index 00000000000..d0de4dc0b28 --- /dev/null +++ b/packages/money-account-balance-service/src/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-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts new file mode 100644 index 00000000000..4b647515f4b --- /dev/null +++ b/packages/money-account-balance-service/src/index.ts @@ -0,0 +1,18 @@ +export { MoneyAccountBalanceService } from './money-account-balance-service'; +export type { + MoneyAccountBalanceServiceActions, + MoneyAccountBalanceServiceEvents, + MoneyAccountBalanceServiceMessenger, +} from './money-account-balance-service'; +export type { + MoneyAccountBalanceServiceGetMusdBalanceAction, + MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction, + MoneyAccountBalanceServiceGetExchangeRateAction, + MoneyAccountBalanceServiceGetMusdEquivalentValueAction, + MoneyAccountBalanceServiceGetVaultApyAction, +} from './money-account-balance-service-method-action-types'; +export type { + ExchangeRateResponse, + MusdEquivalentValueResponse, + NormalizedVaultApyResponse, +} from './response.types'; diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts new file mode 100644 index 00000000000..cfd88332362 --- /dev/null +++ b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts @@ -0,0 +1,78 @@ +/** + * 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 Ethereum 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 Ethereum 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. The rate represents the conversion factor from musdSHFvd shares to + * the underlying mUSD asset. + * + * @param options - The options for the query. + * @param options.staleTime - The stale time for the query. Defaults to 30 seconds. + * @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 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. + */ +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 normalized vault APY response. + */ +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-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts new file mode 100644 index 00000000000..d56278468bf --- /dev/null +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -0,0 +1,725 @@ +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 { 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 constructor options. + * @param args.options - Partial constructor options merged over {@link DEFAULT_CONFIG}. + * @returns The constructed service together with messenger instances and mock stubs. + */ +function createService({ + options = {}, +}: { + 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, + ...DEFAULT_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, + ); +} + +/** + * Configures the Contract mock to route calls to the correct contract method + * based on the address. Used when `getMusdEquivalentValue` creates two + * contracts in the same call — the vault (balanceOf) and the accountant + * (getRate). + * + * @param vaultBalance - The raw uint256 balance string for the vault share contract. + * @param exchangeRate - The raw uint256 rate string for the accountant contract. + */ +function mockContractsByAddress( + vaultBalance: string, + exchangeRate: string, +): void { + const contractMocksByAddress: Record> = { + [MOCK_VAULT_ADDRESS]: { + balanceOf: jest.fn().mockResolvedValue({ toString: () => vaultBalance }), + }, + [MOCK_ACCOUNTANT_ADDRESS]: { + getRate: jest.fn().mockResolvedValue({ toString: () => exchangeRate }), + }, + }; + + MockContract.mockImplementation( + (address) => contractMocksByAddress[address] 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(), + ); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect( + service.getMusdSHFvdBalance(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.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No provider found for chain 0xa4b1'); + }); + }); + + // ---------------------------------------------------------- + // 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', + ); + }); + + it('returns the cached rate when called without options within the default stale window', async () => { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + const { service } = createService(); + + // Seed the cache + await service.getExchangeRate(); + + mockGetRate.mockResolvedValue({ toString: () => '1100000' }); + + // Get cached value + const result = await service.getExchangeRate(); + + expect(result).toStrictEqual({ rate: '1050000' }); + expect(mockGetRate).toHaveBeenCalledTimes(1); + }); + + it('refetches when called with staleTime: 0 even if a cached value exists', async () => { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + const { service } = createService(); + + // Seed the cache + const firstResult = await service.getExchangeRate(); + expect(firstResult).toStrictEqual({ rate: '1050000' }); + + mockGetRate.mockResolvedValue({ toString: () => '1100000' }); + + // Refetch the value + const freshResult = await service.getExchangeRate({ staleTime: 0 }); + + expect(freshResult).toStrictEqual({ rate: '1100000' }); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + }); + + // ---------------------------------------------------------- + // 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 + mockContractsByAddress('2000000', '1100000'); + + 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 () => { + mockContractsByAddress('0', '1100000'); + + 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 = 7, rate = 1_500_000, decimals = 6 + // => (7 * 1_500_000) / 1_000_000 = 10_500_000 / 1_000_000 = 10 (BigInt floors) + mockContractsByAddress('7', '1500000'); + + 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('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({ + options: { 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-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts new file mode 100644 index 00000000000..b23ab8ea2b0 --- /dev/null +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -0,0 +1,404 @@ +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 { + ExchangeRateResponse, + MusdEquivalentValueResponse, + NormalizedVaultApyResponse, +} from './response.types'; +import { VaultApyRawResponseStruct } 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 +>; + +// === 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, + * vaultAddress: '0x...', + * vaultChainId: '0xa4b1', + * accountantAddress: '0x...', + * underlyingTokenAddress: '0x...', + * underlyingTokenDecimals: 6, + * }); + * + * const { balance } = await service.getMusdBalance('0xYourMoneyAccount...'); + * ``` + */ + +type MoneyAccountBalanceServiceOptions = { + messenger: MoneyAccountBalanceServiceMessenger; + vaultAddress: Hex; + vaultChainId: Hex; + accountantAddress: Hex; + underlyingTokenAddress: Hex; + underlyingTokenDecimals: number; + policyOptions?: CreateServicePolicyOptions; +}; + +export class MoneyAccountBalanceService extends BaseDataService< + typeof serviceName, + MoneyAccountBalanceServiceMessenger +> { + readonly #networkName: string; + + readonly #vaultAddress: Hex; + + readonly #vaultChainId: Hex; + + readonly #accountantAddress: Hex; + + readonly #underlyingTokenAddress: Hex; + + readonly #underlyingTokenDecimals: number; + + /** + * Constructs a new MoneyAccountBalanceService. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.vaultAddress - The address of the Veda vault (e.g. musdSHFvd token contract). + * @param args.vaultChainId - The chain ID of the Veda vault. + * @param args.accountantAddress - The address of the Veda Accountant contract. + * @param args.underlyingTokenAddress - The address of the underlying token (e.g. mUSD). Must be on the same chain as the vault. + * @param args.underlyingTokenDecimals - The decimals of the underlying token. + * @param args.policyOptions - Options to pass to `createServicePolicy`, + */ + constructor({ + messenger, + vaultAddress, + vaultChainId, + accountantAddress, + underlyingTokenAddress, + underlyingTokenDecimals, + policyOptions = {}, + }: MoneyAccountBalanceServiceOptions) { + super({ + name: serviceName, + messenger, + policyOptions: { + retryFilterPolicy: handleWhen( + (error) => !(error instanceof VedaResponseValidationError), + ), + ...policyOptions, + }, + }); + + this.#vaultAddress = vaultAddress; + this.#vaultChainId = vaultChainId; + this.#accountantAddress = accountantAddress; + this.#underlyingTokenAddress = underlyingTokenAddress; + this.#underlyingTokenDecimals = underlyingTokenDecimals; + + this.#networkName = + VEDA_API_NETWORK_NAMES[this.#vaultChainId] ?? + DEFAULT_VEDA_API_NETWORK_NAME; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves a Web3Provider for {@link MoneyAccountBalanceServiceOptions.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.#vaultChainId, + ); + + if (!config) { + throw new Error( + `No network configuration found for chain ${this.#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.#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<{ balance: string }> { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdBalance`, accountAddress], + queryFn: async () => { + const balance = await this.#fetchErc20Balance( + this.#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<{ balance: string }> { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], + queryFn: async () => { + const balance = await this.#fetchErc20Balance( + this.#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. + * + * @param options - The options for the query. + * @param options.staleTime - The stale time for the query. Defaults to 30 seconds. + * @returns The exchange rate as a raw uint256 string. + */ + async getExchangeRate({ + staleTime = inMilliseconds(30, Duration.Second), + }: { staleTime?: number } = {}): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getExchangeRate`], + queryFn: async () => { + const provider = this.#getProvider(); + const contract = new Contract( + this.#accountantAddress, + ACCOUNTANT_ABI, + provider, + ); + const rate = await contract.getRate(); + return { rate: rate.toString() }; + }, + staleTime, + }); + } + + /** + * 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.#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.#vaultAddress}`, + VEDA_PERFORMANCE_API_BASE_URL, + ); + + 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, VaultApyRawResponseStruct)) { + throw new VedaResponseValidationError( + 'Malformed response received from Veda performance API', + ); + } + + return normalizeVaultApyResponse(rawResponse); + }, + staleTime: inMilliseconds(5, Duration.Minute), + }); + } +} diff --git a/packages/money-account-balance-service/src/requestNormalization.ts b/packages/money-account-balance-service/src/requestNormalization.ts new file mode 100644 index 00000000000..2a8238dfcad --- /dev/null +++ b/packages/money-account-balance-service/src/requestNormalization.ts @@ -0,0 +1,40 @@ +import { Infer } from '@metamask/superstruct'; + +import type { NormalizedVaultApyResponse } from './response.types'; +import { VaultApyRawResponseStruct } 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, +): NormalizedVaultApyResponse { + 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-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts new file mode 100644 index 00000000000..9f69321fbb9 --- /dev/null +++ b/packages/money-account-balance-service/src/response.types.ts @@ -0,0 +1,48 @@ +/** + * 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 NormalizedVaultApyResponse = { + 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-balance-service/src/structs.ts b/packages/money-account-balance-service/src/structs.ts new file mode 100644 index 00000000000..c892959fe99 --- /dev/null +++ b/packages/money-account-balance-service/src/structs.ts @@ -0,0 +1,46 @@ +import { + array, + number, + optional, + record, + string, + type, +} from '@metamask/superstruct'; + +/** + * Superstruct schema for {@link NormalizedVaultApyResponse}. + * + * 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 VaultApyRawResponseStruct = 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-balance-service/tsconfig.build.json b/packages/money-account-balance-service/tsconfig.build.json new file mode 100644 index 00000000000..0d76f933e7f --- /dev/null +++ b/packages/money-account-balance-service/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-balance-service/tsconfig.json b/packages/money-account-balance-service/tsconfig.json new file mode 100644 index 00000000000..fd635fefdea --- /dev/null +++ b/packages/money-account-balance-service/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../messenger" }, + { "path": "../network-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-balance-service/typedoc.json b/packages/money-account-balance-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-balance-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index b2648bc9e8d..775e450198b 100644 --- a/teams.json +++ b/teams.json @@ -24,6 +24,7 @@ "metamask/gator-permissions-controller": "team-delegation", "metamask/eip-7702-internal-rpc-middleware": "team-delegation", "metamask/earn-controller": "team-earn", + "metamask/money-account-balance-service": "team-earn", "metamask/notification-services-controller": "team-assets", "metamask/compliance-controller": "team-perps", "metamask/perps-controller": "team-perps", diff --git a/yarn.lock b/yarn.lock index 52476281cd9..4a89180e573 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4425,6 +4425,33 @@ __metadata: languageName: node linkType: hard +"@metamask/money-account-balance-service@workspace:packages/money-account-balance-service": + version: 0.0.0-use.local + resolution: "@metamask/money-account-balance-service@workspace:packages/money-account-balance-service" + dependencies: + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/auto-changelog": "npm:^6.0.0" + "@metamask/base-data-service": "npm:^0.1.1" + "@metamask/controller-utils": "npm:^11.20.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" + 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" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/money-account-controller@workspace:packages/money-account-controller": version: 0.0.0-use.local resolution: "@metamask/money-account-controller@workspace:packages/money-account-controller"