From a4d4ddadf872ec66f6e8a0d7c2b94c2b33582d40 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 13:50:30 -0400 Subject: [PATCH 01/19] feat: first iteration of the money-account-balance-service --- .../money-account-controller/package.json | 8 +- .../money-account-controller/src/index.ts | 24 ++ .../constants.ts | 51 +++ ...unt-balance-service-method-action-types.ts | 73 ++++ .../money-account-balance-service.ts | 359 ++++++++++++++++++ .../money-account-balance-service/types.ts | 57 +++ .../tsconfig.build.json | 9 +- .../money-account-controller/tsconfig.json | 5 +- 8 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 packages/money-account-controller/src/money-account-balance-service/constants.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/types.ts diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 8e4f6933efe..9559c468650 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -49,18 +49,24 @@ "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/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", diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index cfc0f01c007..8087e26fb83 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -18,3 +18,27 @@ 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 { + MusdBalanceResponse, + MusdSHFvdBalanceResponse, + ExchangeRateResponse, + MusdEquivalentValueResponse, + VaultApyResponse, + VaultApyBreakdownEntry, +} from './money-account-balance-service/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..3bba7f86b02 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/constants.ts @@ -0,0 +1,51 @@ +import type { Hex } from '@metamask/utils'; + +// TODO: Replace placeholder addresses with actual deployed contract addresses. +// TODO: Rename constants to be more generic. +/** + * Arbitrum USDC (test Vault): 0xaf88d065e77c8cc2239327c5edb3a432268e5831 + */ +export const MUSD_CONTRACT_ADDRESS: Hex = + '0xaf88d065e77c8cc2239327c5edb3a432268e5831'; + +/** + * Arbitrum USDC (test Vault): 0xB5F07d769dD60fE54c97dd53101181073DDf21b2 + */ +// TODO: Rename to Veda Vault address +export const MUSDHFVD_CONTRACT_ADDRESS: Hex = + '0xB5F07d769dD60fE54c97dd53101181073DDf21b2'; + +// TODO: Rename to Veda Accountant address +/** + * Arbitrum Accountant (test Vault): 0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173 + */ +export const ACCOUNTANT_CONTRACT_ADDRESS: Hex = + '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173'; + +// TODO: Use CHAIN_IDS.ARBITRUM instead. +export const VAULT_CHAIN_ID: Hex = '0xa4b1'; // Arbitrum One + +// TODO: Replace with the canonical Veda network identifier for the deployment. +export const VEDA_NETWORK = 'arbitrum'; + +export const MUSD_DECIMALS = 6; + +export const MUSDHFVD_DECIMALS = 6; + +export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; + +/** + * 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. + */ +// TODO: Verify this ABI is correct. +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/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.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts new file mode 100644 index 00000000000..30c2b5a670f --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts @@ -0,0 +1,359 @@ +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 { 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 type { Hex } from '@metamask/utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; + +import { + ACCOUNTANT_ABI, + ACCOUNTANT_CONTRACT_ADDRESS, + MUSD_CONTRACT_ADDRESS, + MUSD_DECIMALS, + MUSDHFVD_CONTRACT_ADDRESS, + VAULT_CHAIN_ID, + VEDA_NETWORK, + VEDA_PERFORMANCE_API_BASE_URL, +} from './constants'; +import type { MoneyAccountBalanceServiceMethodActions } from './money-account-balance-service-method-action-types'; +import type { + ExchangeRateResponse, + MusdBalanceResponse, + MusdEquivalentValueResponse, + MusdSHFvdBalanceResponse, + VaultApyResponse, +} from './types'; + +// === 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, + * }); + * + * const { balance } = await service.getMusdBalance('0xYourMoneyAccount...'); + * ``` + */ +export class MoneyAccountBalanceService extends BaseDataService< + typeof serviceName, + MoneyAccountBalanceServiceMessenger +> { + /** + * 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. + */ + constructor({ + messenger, + policyOptions = {}, + }: { + messenger: MoneyAccountBalanceServiceMessenger; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ + name: serviceName, + messenger, + policyOptions, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves a Web3Provider for {@link VAULT_CHAIN_ID} 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', + VAULT_CHAIN_ID, + ); + + if (!config) { + throw new Error( + `No network configuration found for chain ${VAULT_CHAIN_ID}`, + ); + } + + 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 ${VAULT_CHAIN_ID}`); + } + + return new Web3Provider(networkClient.provider); + } + + /** + * 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 provider = this.#getProvider(); + const contract = new Contract( + MUSD_CONTRACT_ADDRESS, + abiERC20, + provider, + ); + const balance = await contract.balanceOf(accountAddress); + return { balance: balance.toString() }; + }, + 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 provider = this.#getProvider(); + const contract = new Contract( + MUSDHFVD_CONTRACT_ADDRESS, + abiERC20, + provider, + ); + const balance = await contract.balanceOf(accountAddress); + return { balance: balance.toString() } as MusdSHFvdBalanceResponse; + }, + 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( + ACCOUNTANT_CONTRACT_ADDRESS, + ACCOUNTANT_ABI, + provider, + ); + const rate = await contract.getRate(); + return { rate: rate.toString() } as ExchangeRateResponse; + }, + 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. + * + * The Veda Accountant's `getRate()` returns the exchange rate in + * `MUSD_DECIMALS` (6) precision (e.g., `1000000` = 1.0, `1050000` = 1.05). + * Dividing by `10^MUSD_DECIMALS` removes the rate's scaling, producing + * a result in the same 6-decimal raw units as mUSD. + * + * @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) / + BigInt(10 ** MUSD_DECIMALS) + ).toString(); + + return { + musdSHFvdBalance, + exchangeRate, + musdEquivalentValue, + }; + } + + /** + * Fetches the vault's APY and fee breakdown from the Veda performance REST + * API at `api.sevenseas.capital`. + * + * @returns The 7-day trailing net APY, fees, and per-position breakdown. + */ + async getVaultApy(): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getVaultApy`], + queryFn: async () => { + const url = new URL( + `/performance/${VEDA_NETWORK}/${MUSDHFVD_CONTRACT_ADDRESS}`, + 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 json = await response.json(); + + return { + apy: json.apy ?? null, + fees: json.global_apy_breakdown?.fees ?? null, + performanceFees: json.performance_fees ?? null, + apyBreakdown: ( + json.global_apy_breakdown?.real_apy_breakdown ?? [] + ).map( + (entry: { + category?: string; + apy?: number; + allocation?: number; + }) => ({ + category: entry.category ?? 'unknown', + apy: entry.apy ?? null, + allocation: entry.allocation ?? null, + }), + ), + } as VaultApyResponse; + }, + staleTime: inMilliseconds(5, Duration.Minute), + }); + } +} diff --git a/packages/money-account-controller/src/money-account-balance-service/types.ts b/packages/money-account-controller/src/money-account-balance-service/types.ts new file mode 100644 index 00000000000..52efbd1ea3a --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/types.ts @@ -0,0 +1,57 @@ +// TODO: Determine if this is necessary. See if other existing data services have similar type definitions. +// TODO: Validate if types are accurate. +/** + * Response from {@link MoneyAccountBalanceService.getMusdBalance}. + * Balance is a raw uint256 string (no decimal normalization). + */ +export type MusdBalanceResponse = { + balance: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getMusdSHFvdBalance}. + * Balance is a raw uint256 string (no decimal normalization). + */ +export type MusdSHFvdBalanceResponse = { + 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 / 1e18`. + */ +export type MusdEquivalentValueResponse = { + musdSHFvdBalance: string; + exchangeRate: string; + musdEquivalentValue: string; +}; + +/** + * Per-position APY entry from the Veda performance API's + * `global_apy_breakdown.real_apy_breakdown` array. + */ +export type VaultApyBreakdownEntry = { + category: string; + apy: number; + allocation: number; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getVaultApy}. + * All APY / fee values are decimals (multiply by 100 for percentage). + */ +export type VaultApyResponse = { + apy: number; + fees: number; + performanceFees: number; + apyBreakdown: VaultApyBreakdownEntry[]; +}; 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"] } From 7727e889532036378e46b26f2f1d4e6c89f70c50 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 16:41:19 -0400 Subject: [PATCH 02/19] feat: money-account-balance-service cleanup --- .../money-account-controller/package.json | 1 + .../money-account-controller/src/index.ts | 6 +- .../constants.ts | 39 +--- .../money-account-balance-service/errors.ts | 6 + .../money-account-balance-service.ts | 171 +++++++++++------- .../requestNormalization.ts | 38 ++++ .../{types.ts => response.types.ts} | 44 ++--- .../money-account-balance-service/structs.ts | 32 ++++ 8 files changed, 212 insertions(+), 125 deletions(-) create mode 100644 packages/money-account-controller/src/money-account-balance-service/errors.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts rename packages/money-account-controller/src/money-account-balance-service/{types.ts => response.types.ts} (51%) create mode 100644 packages/money-account-controller/src/money-account-balance-service/structs.ts diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 9559c468650..4bfd0c174e8 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -61,6 +61,7 @@ "@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" }, diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index 8087e26fb83..87a38648b99 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -35,10 +35,8 @@ export type { MoneyAccountBalanceServiceGetVaultApyAction, } from './money-account-balance-service/money-account-balance-service-method-action-types'; export type { - MusdBalanceResponse, - MusdSHFvdBalanceResponse, + Erc20BalanceResponse, ExchangeRateResponse, MusdEquivalentValueResponse, VaultApyResponse, - VaultApyBreakdownEntry, -} from './money-account-balance-service/types'; +} 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 index 3bba7f86b02..2ea80a14959 100644 --- a/packages/money-account-controller/src/money-account-balance-service/constants.ts +++ b/packages/money-account-controller/src/money-account-balance-service/constants.ts @@ -1,45 +1,18 @@ -import type { Hex } from '@metamask/utils'; +import { Hex } from '@metamask/utils'; -// TODO: Replace placeholder addresses with actual deployed contract addresses. -// TODO: Rename constants to be more generic. -/** - * Arbitrum USDC (test Vault): 0xaf88d065e77c8cc2239327c5edb3a432268e5831 - */ -export const MUSD_CONTRACT_ADDRESS: Hex = - '0xaf88d065e77c8cc2239327c5edb3a432268e5831'; - -/** - * Arbitrum USDC (test Vault): 0xB5F07d769dD60fE54c97dd53101181073DDf21b2 - */ -// TODO: Rename to Veda Vault address -export const MUSDHFVD_CONTRACT_ADDRESS: Hex = - '0xB5F07d769dD60fE54c97dd53101181073DDf21b2'; - -// TODO: Rename to Veda Accountant address -/** - * Arbitrum Accountant (test Vault): 0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173 - */ -export const ACCOUNTANT_CONTRACT_ADDRESS: Hex = - '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173'; - -// TODO: Use CHAIN_IDS.ARBITRUM instead. -export const VAULT_CHAIN_ID: Hex = '0xa4b1'; // Arbitrum One - -// TODO: Replace with the canonical Veda network identifier for the deployment. -export const VEDA_NETWORK = 'arbitrum'; - -export const MUSD_DECIMALS = 6; +export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; -export const MUSDHFVD_DECIMALS = 6; +export const VEDA_API_NETWORK_NAMES: Record = { + '0xa4b1': 'arbitrum', +}; -export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; +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. */ -// TODO: Verify this ABI is correct. export const ACCOUNTANT_ABI = [ { inputs: [], 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.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts index 30c2b5a670f..de7b73ae8d7 100644 --- 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 @@ -7,34 +7,33 @@ import type { } from '@metamask/base-data-service'; import { BaseDataService } from '@metamask/base-data-service'; import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; -import { HttpError } 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, - ACCOUNTANT_CONTRACT_ADDRESS, - MUSD_CONTRACT_ADDRESS, - MUSD_DECIMALS, - MUSDHFVD_CONTRACT_ADDRESS, - VAULT_CHAIN_ID, - VEDA_NETWORK, + 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, - MusdBalanceResponse, MusdEquivalentValueResponse, - MusdSHFvdBalanceResponse, VaultApyResponse, -} from './types'; +} from './response.types'; +import { VaultApyResponseStruct } from './structs'; // === GENERAL === @@ -110,6 +109,24 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< 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 === /** @@ -126,6 +143,13 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< * ```ts * const service = new MoneyAccountBalanceService({ * messenger: moneyAccountBalanceServiceMessenger, + * config: { + * vaultAddress: '0x...', + * vaultChainId: '0xa4b1', + * accountantAddress: '0x...', + * underlyingTokenAddress: '0x...', + * underlyingTokenDecimals: 6, + * }, * }); * * const { balance } = await service.getMusdBalance('0xYourMoneyAccount...'); @@ -135,6 +159,10 @@ export class MoneyAccountBalanceService extends BaseDataService< typeof serviceName, MoneyAccountBalanceServiceMessenger > { + readonly #config: MoneyAccountBalanceServiceConfig; + + readonly #networkName: string; + /** * Constructs a new MoneyAccountBalanceService. * @@ -142,20 +170,37 @@ export class MoneyAccountBalanceService extends BaseDataService< * @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: { + ...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, @@ -163,7 +208,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Resolves a Web3Provider for {@link VAULT_CHAIN_ID} by looking up the + * 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. @@ -173,12 +218,12 @@ export class MoneyAccountBalanceService extends BaseDataService< #getProvider(): Web3Provider { const config = this.messenger.call( 'NetworkController:getNetworkConfigurationByChainId', - VAULT_CHAIN_ID, + this.#config.vaultChainId, ); if (!config) { throw new Error( - `No network configuration found for chain ${VAULT_CHAIN_ID}`, + `No network configuration found for chain ${this.#config.vaultChainId}`, ); } @@ -191,30 +236,46 @@ export class MoneyAccountBalanceService extends BaseDataService< ); if (!networkClient?.provider) { - throw new Error(`No provider found for chain ${VAULT_CHAIN_ID}`); + 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 { + async getMusdBalance(accountAddress: Hex): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getMusdBalance`, accountAddress], queryFn: async () => { - const provider = this.#getProvider(); - const contract = new Contract( - MUSD_CONTRACT_ADDRESS, - abiERC20, - provider, + const balance = await this.#fetchErc20Balance( + this.#config.underlyingTokenAddress, + accountAddress, ); - const balance = await contract.balanceOf(accountAddress); - return { balance: balance.toString() }; + return { balance }; }, staleTime: inMilliseconds(30, Duration.Second), }); @@ -229,18 +290,15 @@ export class MoneyAccountBalanceService extends BaseDataService< */ async getMusdSHFvdBalance( accountAddress: Hex, - ): Promise { + ): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], queryFn: async () => { - const provider = this.#getProvider(); - const contract = new Contract( - MUSDHFVD_CONTRACT_ADDRESS, - abiERC20, - provider, + const balance = await this.#fetchErc20Balance( + this.#config.vaultAddress, + accountAddress, ); - const balance = await contract.balanceOf(accountAddress); - return { balance: balance.toString() } as MusdSHFvdBalanceResponse; + return { balance }; }, staleTime: inMilliseconds(30, Duration.Second), }); @@ -259,12 +317,12 @@ export class MoneyAccountBalanceService extends BaseDataService< queryFn: async () => { const provider = this.#getProvider(); const contract = new Contract( - ACCOUNTANT_CONTRACT_ADDRESS, + this.#config.accountantAddress, ACCOUNTANT_ABI, provider, ); const rate = await contract.getRate(); - return { rate: rate.toString() } as ExchangeRateResponse; + return { rate: rate.toString() }; }, staleTime: inMilliseconds(30, Duration.Second), }); @@ -276,11 +334,6 @@ export class MoneyAccountBalanceService extends BaseDataService< * values when available within their staleTime windows), then multiplies * them. * - * The Veda Accountant's `getRate()` returns the exchange rate in - * `MUSD_DECIMALS` (6) precision (e.g., `1000000` = 1.0, `1050000` = 1.05). - * Dividing by `10^MUSD_DECIMALS` removes the rate's scaling, producing - * a result in the same 6-decimal raw units as mUSD. - * * @param accountAddress - The Money account's Ethereum address. * @returns The musdSHFvd balance, exchange rate, and computed * mUSD-equivalent value as raw uint256 strings. @@ -296,9 +349,10 @@ export class MoneyAccountBalanceService extends BaseDataService< const balanceBigInt = BigInt(musdSHFvdBalance); const rateBigInt = BigInt(exchangeRate); + const musdEquivalentValue = ( (balanceBigInt * rateBigInt) / - BigInt(10 ** MUSD_DECIMALS) + 10n ** BigInt(this.#config.underlyingTokenDecimals) ).toString(); return { @@ -309,18 +363,17 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Fetches the vault's APY and fee breakdown from the Veda performance REST - * API at `api.sevenseas.capital`. + * 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. + * @returns The normalized vault APY response. */ async getVaultApy(): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getVaultApy`], queryFn: async () => { const url = new URL( - `/performance/${VEDA_NETWORK}/${MUSDHFVD_CONTRACT_ADDRESS}`, - VEDA_PERFORMANCE_API_BASE_URL, + `/performance/${this.#networkName}/${this.#config.vaultAddress}`, + this.#config.vedaApiBaseUrl, ); const response = await fetch(url); @@ -332,26 +385,16 @@ export class MoneyAccountBalanceService extends BaseDataService< ); } - const json = await response.json(); - - return { - apy: json.apy ?? null, - fees: json.global_apy_breakdown?.fees ?? null, - performanceFees: json.performance_fees ?? null, - apyBreakdown: ( - json.global_apy_breakdown?.real_apy_breakdown ?? [] - ).map( - (entry: { - category?: string; - apy?: number; - allocation?: number; - }) => ({ - category: entry.category ?? 'unknown', - apy: entry.apy ?? null, - allocation: entry.allocation ?? null, - }), - ), - } as VaultApyResponse; + 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..c66fb09ec1d --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts @@ -0,0 +1,38 @@ +import { Infer } from '@metamask/superstruct'; + +import { VaultApyResponseStruct } from './structs'; +import { VaultApyResponse } from './response.types'; + +/** + * 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: { + fee: response.global_apy_breakdown.fee, + maturityApy: response.global_apy_breakdown.maturity_apy, + realApy: response.global_apy_breakdown.real_apy, + }, + 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/types.ts b/packages/money-account-controller/src/money-account-balance-service/response.types.ts similarity index 51% rename from packages/money-account-controller/src/money-account-balance-service/types.ts rename to packages/money-account-controller/src/money-account-balance-service/response.types.ts index 52efbd1ea3a..e9ec1cbb9af 100644 --- a/packages/money-account-controller/src/money-account-balance-service/types.ts +++ b/packages/money-account-controller/src/money-account-balance-service/response.types.ts @@ -1,18 +1,8 @@ -// TODO: Determine if this is necessary. See if other existing data services have similar type definitions. -// TODO: Validate if types are accurate. /** - * Response from {@link MoneyAccountBalanceService.getMusdBalance}. + * Response from {@link MoneyAccountBalanceService.#fetchErc20Balance}. * Balance is a raw uint256 string (no decimal normalization). */ -export type MusdBalanceResponse = { - balance: string; -}; - -/** - * Response from {@link MoneyAccountBalanceService.getMusdSHFvdBalance}. - * Balance is a raw uint256 string (no decimal normalization). - */ -export type MusdSHFvdBalanceResponse = { +export type Erc20BalanceResponse = { balance: string; }; @@ -27,7 +17,7 @@ export type ExchangeRateResponse = { /** * Response from {@link MoneyAccountBalanceService.getMusdEquivalentValue}. * All values are raw uint256 strings. The `musdEquivalentValue` is - * `musdSHFvdBalance * exchangeRate / 1e18`. + * `musdSHFvdBalance * exchangeRate / 10^underlyingTokenDecimals` (= 1e6 for mUSD). */ export type MusdEquivalentValueResponse = { musdSHFvdBalance: string; @@ -35,23 +25,29 @@ export type MusdEquivalentValueResponse = { musdEquivalentValue: string; }; -/** - * Per-position APY entry from the Veda performance API's - * `global_apy_breakdown.real_apy_breakdown` array. - */ -export type VaultApyBreakdownEntry = { - category: string; - apy: number; - allocation: number; -}; - /** * Response from {@link MoneyAccountBalanceService.getVaultApy}. * All APY / fee values are decimals (multiply by 100 for percentage). */ 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; - apyBreakdown: VaultApyBreakdownEntry[]; + 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..34739d3454b --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/structs.ts @@ -0,0 +1,32 @@ +import { array, number, 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. + */ +export const VaultApyResponseStruct = type({ + Response: type({ + aggregation_period: string(), + apy: number(), + chain_allocation: record(string(), number()), + fees: number(), + global_apy_breakdown: type({ + fee: number(), + maturity_apy: number(), + real_apy: number(), + }), + performance_fees: number(), + real_apy_breakdown: array( + type({ + allocation: number(), + apy: number(), + apy_net: number(), + chain: string(), + protocol: string(), + }), + ), + timestamp: string(), + }), +}); From aa31fa36be22349e9c57518a2c8c0bcc597bac82 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 16:46:35 -0400 Subject: [PATCH 03/19] feat: update yarn.lock --- yarn.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yarn.lock b/yarn.lock index 5d0a8876af7..84082d9db4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4406,14 +4406,21 @@ __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" From 24d7d65e9c232f3e853cd6a2e1ee6b1b3e6985f6 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 18:17:29 -0400 Subject: [PATCH 04/19] feat: made all VaultApyResponse fields optional except for apy and timestamp. Vaults aren't guaranteed to have the other properties when there's no activity --- .../requestNormalization.ts | 16 ++++--- .../response.types.ts | 31 +++++++------ .../money-account-balance-service/structs.ts | 46 ++++++++++++------- yarn.lock | 1 + 4 files changed, 57 insertions(+), 37 deletions(-) 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 index c66fb09ec1d..ee3df074012 100644 --- a/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts +++ b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts @@ -1,7 +1,7 @@ import { Infer } from '@metamask/superstruct'; -import { VaultApyResponseStruct } from './structs'; import { VaultApyResponse } from './response.types'; +import { VaultApyResponseStruct } from './structs'; /** * Normalizes the raw response from the Veda performance API into the expected @@ -20,13 +20,15 @@ export function normalizeVaultApyResponse( apy: response.apy, chainAllocation: response.chain_allocation, fees: response.fees, - globalApyBreakdown: { - fee: response.global_apy_breakdown.fee, - maturityApy: response.global_apy_breakdown.maturity_apy, - realApy: response.global_apy_breakdown.real_apy, - }, + 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) => ({ + realApyBreakdown: response.real_apy_breakdown?.map((item) => ({ allocation: item.allocation, apy: item.apy, apyNet: item.apy_net, 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 index e9ec1cbb9af..7f119010b35 100644 --- 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 @@ -28,26 +28,29 @@ export type MusdEquivalentValueResponse = { /** * 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" + aggregationPeriod?: string; // E.g. "7 days" apy: number; - chainAllocation: { + chainAllocation?: { [network: string]: number; }; - fees: number; - globalApyBreakdown: { - fee: number; - maturityApy: number; - realApy: number; + fees?: number; + globalApyBreakdown?: { + fee?: number; + maturityApy?: number; + realApy?: number; }; - performanceFees: number; - realApyBreakdown: { - allocation: number; - apy: number; - apyNet: number; - chain: string; - protocol: string; + 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 index 34739d3454b..a8bd4f0f082 100644 --- a/packages/money-account-controller/src/money-account-balance-service/structs.ts +++ b/packages/money-account-controller/src/money-account-balance-service/structs.ts @@ -1,32 +1,46 @@ -import { array, number, record, string, type } from '@metamask/superstruct'; +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: string(), + aggregation_period: optional(string()), apy: number(), - chain_allocation: record(string(), number()), - fees: number(), - global_apy_breakdown: type({ - fee: number(), - maturity_apy: number(), - real_apy: number(), - }), - performance_fees: number(), - real_apy_breakdown: array( + chain_allocation: optional(record(string(), number())), + fees: optional(number()), + global_apy_breakdown: optional( type({ - allocation: number(), - apy: number(), - apy_net: number(), - chain: string(), - protocol: string(), + 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/yarn.lock b/yarn.lock index 84082d9db4f..5cadadca844 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4427,6 +4427,7 @@ __metadata: 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" From 6e596eb2f24bee51f676e949e7c836fb07e72035 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 18:25:29 -0400 Subject: [PATCH 05/19] feat: add tests --- .../money-account-controller/package.json | 1 + .../money-account-balance-service.test.ts | 693 ++++++++++++++++++ 2 files changed, 694 insertions(+) create mode 100644 packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 4bfd0c174e8..dfa1b824034 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -72,6 +72,7 @@ "@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/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'); + }); +}); From e755f6955f0380a008e6a7ced9f3cac283c0ccbe Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Mon, 13 Apr 2026 13:00:41 -0400 Subject: [PATCH 06/19] chore: updated README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2d6154989be..7febd6d3d75 100644 --- a/README.md +++ b/README.md @@ -352,8 +352,11 @@ linkStyle default opacity:0.5 message_manager --> messenger; money_account_controller --> accounts_controller; money_account_controller --> base_controller; + money_account_controller --> base_data_service; + money_account_controller --> controller_utils; money_account_controller --> keyring_controller; money_account_controller --> messenger; + money_account_controller --> network_controller; multichain_account_service --> accounts_controller; multichain_account_service --> base_controller; multichain_account_service --> keyring_controller; From 4d99a0b70f18fbfc0c2c357939c79e38f023855a Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Mon, 13 Apr 2026 13:41:51 -0400 Subject: [PATCH 07/19] feat: cleanup --- .../money-account-controller/src/index.ts | 1 - ...unt-balance-service-method-action-types.ts | 1 - .../money-account-balance-service.test.ts | 89 ++++++++++--------- .../money-account-balance-service.ts | 11 +-- 4 files changed, 53 insertions(+), 49 deletions(-) diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index 87a38648b99..ed5f75e11bf 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -35,7 +35,6 @@ export type { MoneyAccountBalanceServiceGetVaultApyAction, } from './money-account-balance-service/money-account-balance-service-method-action-types'; export type { - Erc20BalanceResponse, ExchangeRateResponse, MusdEquivalentValueResponse, VaultApyResponse, 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 index f5e74c95a99..26e9036026d 100644 --- 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 @@ -1,4 +1,3 @@ -// 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. 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 index 748c331a5bf..3790d189138 100644 --- 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 @@ -235,6 +235,33 @@ function mockAccountantGetRate(rate: string): void { ); } +/** + * 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 // ============================================================ @@ -370,6 +397,24 @@ describe('MoneyAccountBalanceService', () => { 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'); + }); }); // ---------------------------------------------------------- @@ -426,19 +471,7 @@ describe('MoneyAccountBalanceService', () => { 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, - ); + mockContractsByAddress('2000000', '1100000'); const { service } = createService(); @@ -452,17 +485,7 @@ describe('MoneyAccountBalanceService', () => { }); 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, - ); + mockContractsByAddress('0', '1100000'); const { service } = createService(); @@ -472,23 +495,9 @@ describe('MoneyAccountBalanceService', () => { }); 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 + // 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, - ); + mockContractsByAddress('7', '1500000'); const { service } = createService(); 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 index de7b73ae8d7..24038c52c63 100644 --- 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 @@ -28,7 +28,6 @@ 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, @@ -185,10 +184,10 @@ export class MoneyAccountBalanceService extends BaseDataService< name: serviceName, messenger, policyOptions: { - ...policyOptions, retryFilterPolicy: handleWhen( (error) => !(error instanceof VedaResponseValidationError), ), + ...policyOptions, }, }); @@ -254,7 +253,7 @@ export class MoneyAccountBalanceService extends BaseDataService< async #fetchErc20Balance( contractAddress: Hex, accountAddress: Hex, - ): Promise { + ): Promise { const provider = this.#getProvider(); const contract = new Contract(contractAddress, abiERC20, provider); const balance = await contract.balanceOf(accountAddress); @@ -267,7 +266,7 @@ export class MoneyAccountBalanceService extends BaseDataService< * @param accountAddress - The Money account's Ethereum address. * @returns The mUSD balance as a raw uint256 string. */ - async getMusdBalance(accountAddress: Hex): Promise { + async getMusdBalance(accountAddress: Hex): Promise<{ balance: string }> { return this.fetchQuery({ queryKey: [`${this.name}:getMusdBalance`, accountAddress], queryFn: async () => { @@ -288,9 +287,7 @@ export class MoneyAccountBalanceService extends BaseDataService< * @param accountAddress - The Money account's Ethereum address. * @returns The musdSHFvd balance as a raw uint256 string. */ - async getMusdSHFvdBalance( - accountAddress: Hex, - ): Promise { + async getMusdSHFvdBalance(accountAddress: Hex): Promise<{ balance: string }> { return this.fetchQuery({ queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], queryFn: async () => { From 328e9a42cca90e0c4c83c50574f5970223ce383c Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 11:51:37 -0400 Subject: [PATCH 08/19] feat: added optional staleTime param to getExchangeRate to allow imperative fetching of live value (e.g. withdrawal flow) --- .../money-account-balance-service.test.ts | 43 +++++++++++++++++++ .../money-account-balance-service.ts | 8 +++- 2 files changed, 49 insertions(+), 2 deletions(-) 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 index 3790d189138..8108bc396b6 100644 --- 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 @@ -461,6 +461,49 @@ describe('MoneyAccountBalanceService', () => { '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); + }); }); // ---------------------------------------------------------- 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 index 24038c52c63..429f241df53 100644 --- 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 @@ -306,9 +306,13 @@ export class MoneyAccountBalanceService extends BaseDataService< * 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(): Promise { + async getExchangeRate(options?: { + staleTime?: number; + }): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getExchangeRate`], queryFn: async () => { @@ -321,7 +325,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const rate = await contract.getRate(); return { rate: rate.toString() }; }, - staleTime: inMilliseconds(30, Duration.Second), + staleTime: options?.staleTime ?? inMilliseconds(30, Duration.Second), }); } From 61acc24805d1cfe3211c88c254ba5be46f29d7e8 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 12:38:23 -0400 Subject: [PATCH 09/19] feat: broke out money-account-balance-service into its own package --- .github/CODEOWNERS | 3 + README.md | 8 +- .../CHANGELOG.md | 19 +++++ .../money-account-balance-service/LICENSE | 21 +++++ .../money-account-balance-service/README.md | 15 ++++ .../jest.config.js | 24 ++++++ .../package.json | 82 +++++++++++++++++++ .../src}/constants.ts | 0 .../src}/errors.ts | 0 .../src/index.ts | 21 +++++ ...unt-balance-service-method-action-types.ts | 0 .../money-account-balance-service.test.ts | 0 .../src}/money-account-balance-service.ts | 0 .../src}/requestNormalization.ts | 2 +- .../src}/response.types.ts | 0 .../src}/structs.ts | 0 .../tsconfig.build.json | 15 ++++ .../tsconfig.json | 13 +++ .../typedoc.json | 7 ++ .../money-account-controller/package.json | 8 -- .../money-account-controller/src/index.ts | 21 ----- .../tsconfig.build.json | 9 +- .../money-account-controller/tsconfig.json | 5 +- teams.json | 1 + yarn.lock | 35 ++++++-- 25 files changed, 256 insertions(+), 53 deletions(-) create mode 100644 packages/money-account-balance-service/CHANGELOG.md create mode 100644 packages/money-account-balance-service/LICENSE create mode 100644 packages/money-account-balance-service/README.md create mode 100644 packages/money-account-balance-service/jest.config.js create mode 100644 packages/money-account-balance-service/package.json rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/constants.ts (100%) rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/errors.ts (100%) create mode 100644 packages/money-account-balance-service/src/index.ts rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/money-account-balance-service-method-action-types.ts (100%) rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/money-account-balance-service.test.ts (100%) rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/money-account-balance-service.ts (100%) rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/requestNormalization.ts (95%) rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/response.types.ts (100%) rename packages/{money-account-controller/src/money-account-balance-service => money-account-balance-service/src}/structs.ts (100%) create mode 100644 packages/money-account-balance-service/tsconfig.build.json create mode 100644 packages/money-account-balance-service/tsconfig.json create mode 100644 packages/money-account-balance-service/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7da370ce3fe..a08312b9932 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 @MetaMask/core-platform ## 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 7febd6d3d75..1f94b974401 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,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,13 +351,14 @@ 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 --> base_data_service; - money_account_controller --> controller_utils; money_account_controller --> keyring_controller; money_account_controller --> messenger; - money_account_controller --> network_controller; multichain_account_service --> accounts_controller; multichain_account_service --> base_controller; multichain_account_service --> 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..f1d6faeb925 --- /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 ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - 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..376d0e689e7 --- /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": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-balance-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "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" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "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": "^3.4.4", + "@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" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/money-account-controller/src/money-account-balance-service/constants.ts b/packages/money-account-balance-service/src/constants.ts similarity index 100% rename from packages/money-account-controller/src/money-account-balance-service/constants.ts rename to packages/money-account-balance-service/src/constants.ts diff --git a/packages/money-account-controller/src/money-account-balance-service/errors.ts b/packages/money-account-balance-service/src/errors.ts similarity index 100% rename from packages/money-account-controller/src/money-account-balance-service/errors.ts rename to packages/money-account-balance-service/src/errors.ts 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..dc3bc72c34f --- /dev/null +++ b/packages/money-account-balance-service/src/index.ts @@ -0,0 +1,21 @@ +export { + MoneyAccountBalanceService, + serviceName as moneyAccountBalanceServiceName, +} 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, + VaultApyResponse, +} from './response.types'; diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts similarity index 100% rename from packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts rename to packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts similarity index 100% rename from packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts rename to packages/money-account-balance-service/src/money-account-balance-service.test.ts diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts similarity index 100% rename from packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts rename to packages/money-account-balance-service/src/money-account-balance-service.ts diff --git a/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts b/packages/money-account-balance-service/src/requestNormalization.ts similarity index 95% rename from packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts rename to packages/money-account-balance-service/src/requestNormalization.ts index ee3df074012..2f5ae2c816e 100644 --- a/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts +++ b/packages/money-account-balance-service/src/requestNormalization.ts @@ -1,6 +1,6 @@ import { Infer } from '@metamask/superstruct'; -import { VaultApyResponse } from './response.types'; +import type { VaultApyResponse } from './response.types'; import { VaultApyResponseStruct } from './structs'; /** diff --git a/packages/money-account-controller/src/money-account-balance-service/response.types.ts b/packages/money-account-balance-service/src/response.types.ts similarity index 100% rename from packages/money-account-controller/src/money-account-balance-service/response.types.ts rename to packages/money-account-balance-service/src/response.types.ts diff --git a/packages/money-account-controller/src/money-account-balance-service/structs.ts b/packages/money-account-balance-service/src/structs.ts similarity index 100% rename from packages/money-account-controller/src/money-account-balance-service/structs.ts rename to packages/money-account-balance-service/src/structs.ts 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/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index dfa1b824034..fdc99f8b27d 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -49,19 +49,12 @@ "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" }, @@ -72,7 +65,6 @@ "@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 ed5f75e11bf..cfc0f01c007 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -18,24 +18,3 @@ 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 { - ExchangeRateResponse, - MusdEquivalentValueResponse, - VaultApyResponse, -} from './money-account-balance-service/response.types'; diff --git a/packages/money-account-controller/tsconfig.build.json b/packages/money-account-controller/tsconfig.build.json index be5597139b8..8fa5f6bf61b 100644 --- a/packages/money-account-controller/tsconfig.build.json +++ b/packages/money-account-controller/tsconfig.build.json @@ -9,20 +9,13 @@ { "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": "../network-controller/tsconfig.build.json" } + { "path": "../messenger/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/money-account-controller/tsconfig.json b/packages/money-account-controller/tsconfig.json index 4d2655c31a4..e1b9b25e4a4 100644 --- a/packages/money-account-controller/tsconfig.json +++ b/packages/money-account-controller/tsconfig.json @@ -5,12 +5,9 @@ }, "references": [ { "path": "../base-controller" }, - { "path": "../base-data-service" }, { "path": "../accounts-controller" }, - { "path": "../controller-utils" }, { "path": "../keyring-controller" }, - { "path": "../messenger" }, - { "path": "../network-controller" } + { "path": "../messenger" } ], "include": ["../../types", "./src"] } 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 5cadadca844..1961dc31211 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4402,32 +4402,51 @@ __metadata: languageName: node linkType: hard -"@metamask/money-account-controller@workspace:packages/money-account-controller": +"@metamask/money-account-balance-service@workspace:packages/money-account-balance-service": version: 0.0.0-use.local - resolution: "@metamask/money-account-controller@workspace:packages/money-account-controller" + 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/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/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" + dependencies: + "@metamask/accounts-controller": "npm:^37.2.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.1" "@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" From 9b51e570388226b42e15e062396b9eba62bc87e1 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 13:01:22 -0400 Subject: [PATCH 10/19] feat: reverted changes to packages/money-account-controller/package.json --- packages/money-account-controller/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index fdc99f8b27d..f9acda17e5b 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "description": "MetaMask Money account controller", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-controller#readme", "bugs": { @@ -55,12 +55,12 @@ "@metamask/keyring-api": "^21.6.0", "@metamask/keyring-controller": "^25.2.0", "@metamask/messenger": "^1.1.1", - "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, "devDependencies": { - "@metamask/auto-changelog": "^3.4.4", + "@metamask/auto-changelog": "^6.0.0", "@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", From e21594d07c3fd169e27e869d702377397096b193 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 13:08:18 -0400 Subject: [PATCH 11/19] feat: updated yarn.lock --- yarn.lock | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index f11d995f5dc..617841cbf77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2919,6 +2919,21 @@ __metadata: languageName: node linkType: hard +"@metamask/auto-changelog@npm:^3.4.4": + version: 3.4.4 + resolution: "@metamask/auto-changelog@npm:3.4.4" + dependencies: + diff: "npm:^5.0.0" + execa: "npm:^5.1.1" + prettier: "npm:^2.8.8" + semver: "npm:^7.3.5" + yargs: "npm:^17.0.1" + bin: + auto-changelog: dist/cli.js + checksum: 10/70e98529a153ebeab10410dbc3f567014999f77ed82f2b52f1b36501b28a4e3614c809a90c89600a739d7710595bfecc30e2260410e6afac7539f8db65a48f2c + languageName: node + linkType: hard + "@metamask/auto-changelog@npm:^4.0.0": version: 4.1.0 resolution: "@metamask/auto-changelog@npm:4.1.0" @@ -13130,7 +13145,7 @@ __metadata: languageName: node linkType: hard -"prettier-2@npm:prettier@^2.8.8": +"prettier-2@npm:prettier@^2.8.8, prettier@npm:^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: From 293279936751c1327de24f331cf86e441242ebd5 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 13:14:37 -0400 Subject: [PATCH 12/19] feat: resolved incorrect @metamask/auto-changelog version used money-account-balance-service --- .../package.json | 6 +++--- yarn.lock | 19 ++----------------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/money-account-balance-service/package.json b/packages/money-account-balance-service/package.json index 376d0e689e7..b85d51495dc 100644 --- a/packages/money-account-balance-service/package.json +++ b/packages/money-account-balance-service/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "description": "Data service for fetching Money account balances, exchange rates, and vault APY", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-balance-service#readme", "bugs": { @@ -60,7 +60,7 @@ "@metamask/utils": "^11.9.0" }, "devDependencies": { - "@metamask/auto-changelog": "^3.4.4", + "@metamask/auto-changelog": "^6.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index 617841cbf77..38338d3e6d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2919,21 +2919,6 @@ __metadata: languageName: node linkType: hard -"@metamask/auto-changelog@npm:^3.4.4": - version: 3.4.4 - resolution: "@metamask/auto-changelog@npm:3.4.4" - dependencies: - diff: "npm:^5.0.0" - execa: "npm:^5.1.1" - prettier: "npm:^2.8.8" - semver: "npm:^7.3.5" - yargs: "npm:^17.0.1" - bin: - auto-changelog: dist/cli.js - checksum: 10/70e98529a153ebeab10410dbc3f567014999f77ed82f2b52f1b36501b28a4e3614c809a90c89600a739d7710595bfecc30e2260410e6afac7539f8db65a48f2c - languageName: node - linkType: hard - "@metamask/auto-changelog@npm:^4.0.0": version: 4.1.0 resolution: "@metamask/auto-changelog@npm:4.1.0" @@ -4446,7 +4431,7 @@ __metadata: dependencies: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/auto-changelog": "npm:^3.4.4" + "@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" @@ -13145,7 +13130,7 @@ __metadata: languageName: node linkType: hard -"prettier-2@npm:prettier@^2.8.8, prettier@npm:^2.8.8": +"prettier-2@npm:prettier@^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: From c05c5fb31c9ae992bea44457d8c088f162d7aba2 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 13:22:03 -0400 Subject: [PATCH 13/19] feat: updated readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1f94b974401..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) From 0bd238dfb9edbaeaca2b4b845c75fd151b4a6be1 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 13:31:25 -0400 Subject: [PATCH 14/19] feat: fixed formatting issues in package.json --- .../package.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/money-account-balance-service/package.json b/packages/money-account-balance-service/package.json index b85d51495dc..cf2b5faed6d 100644 --- a/packages/money-account-balance-service/package.json +++ b/packages/money-account-balance-service/package.json @@ -10,12 +10,17 @@ "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", + "files": [ + "dist/" + ], "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", "exports": { ".": { "import": { @@ -29,11 +34,10 @@ }, "./package.json": "./package.json" }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.cts", - "files": [ - "dist/" - ], + "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", @@ -74,9 +78,5 @@ }, "engines": { "node": "^18.18 || >=20" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" } } From 146f0047c0eab7aeac9b023cc49ce04bfd15b69b Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 13:35:07 -0400 Subject: [PATCH 15/19] feat: updated money-account-balance-service changelog --- packages/money-account-balance-service/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index f1d6faeb925..5b58a9b20e5 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `MoneyAccountBalanceService` data service ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) +- 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`) From cc71b5c28b5d61823d9a8d86176bd9af6ab70a3a Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 14:57:44 -0400 Subject: [PATCH 16/19] feat: generated method-action-types --- ...unt-balance-service-method-action-types.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 index 26e9036026d..cfd88332362 100644 --- 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 @@ -8,7 +8,7 @@ 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. + * @param accountAddress - The Money account's Ethereum address. * @returns The mUSD balance as a raw uint256 string. */ export type MoneyAccountBalanceServiceGetMusdBalanceAction = { @@ -20,7 +20,7 @@ export type MoneyAccountBalanceServiceGetMusdBalanceAction = { * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given * account address via RPC. * - * @param accountAddress - The Money account's address. + * @param accountAddress - The Money account's Ethereum address. * @returns The musdSHFvd balance as a raw uint256 string. */ export type MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction = { @@ -29,8 +29,12 @@ export type MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction = { }; /** - * Fetches the current exchange rate from the Veda Accountant contract via RPC. + * 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 = { @@ -41,10 +45,12 @@ export type MoneyAccountBalanceServiceGetExchangeRateAction = { /** * 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. + * values when available within their staleTime windows), then multiplies + * them. * - * @param accountAddress - The Money account's address. - * @returns The musdSHFvd balance, exchange rate, and computed mUSD-equivalent value. + * @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`; @@ -54,7 +60,7 @@ export type MoneyAccountBalanceServiceGetMusdEquivalentValueAction = { /** * 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. + * @returns The normalized vault APY response. */ export type MoneyAccountBalanceServiceGetVaultApyAction = { type: `MoneyAccountBalanceService:getVaultApy`; From b8b1a3a81f33f73c0c61358a71482140b58a18f8 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 15:11:38 -0400 Subject: [PATCH 17/19] feat: resolving cursor bug bot comments --- packages/money-account-balance-service/src/index.ts | 1 + .../money-account-balance-service/src/response.types.ts | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts index dc3bc72c34f..57eab8b5a31 100644 --- a/packages/money-account-balance-service/src/index.ts +++ b/packages/money-account-balance-service/src/index.ts @@ -6,6 +6,7 @@ export type { MoneyAccountBalanceServiceActions, MoneyAccountBalanceServiceEvents, MoneyAccountBalanceServiceMessenger, + MoneyAccountBalanceServiceConfig, } from './money-account-balance-service'; export type { MoneyAccountBalanceServiceGetMusdBalanceAction, diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts index 7f119010b35..b78a956c093 100644 --- a/packages/money-account-balance-service/src/response.types.ts +++ b/packages/money-account-balance-service/src/response.types.ts @@ -1,11 +1,3 @@ -/** - * 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()`. From 30334d64d219212209c275c0017798a7a9ec3fd5 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 15:12:53 -0400 Subject: [PATCH 18/19] feat: updated money-account-balance-service codeowner to earn team --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a08312b9932..91c61401f6c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -39,7 +39,7 @@ ## Earn Team /packages/earn-controller @MetaMask/earn -/packages/money-account-balance-service @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service @MetaMask/earn ## Social AI Team /packages/ai-controllers @MetaMask/social-ai From 2deb59b447f2b70d1627882bbe28c2df11af3699 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 14 Apr 2026 16:48:19 -0400 Subject: [PATCH 19/19] feat: changes based on core-platform review --- .../src/index.ts | 8 +- .../src/money-account-balance-service.test.ts | 30 +---- .../src/money-account-balance-service.ts | 121 +++++++++--------- .../src/requestNormalization.ts | 8 +- .../src/response.types.ts | 2 +- .../src/structs.ts | 4 +- 6 files changed, 75 insertions(+), 98 deletions(-) diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts index 57eab8b5a31..4b647515f4b 100644 --- a/packages/money-account-balance-service/src/index.ts +++ b/packages/money-account-balance-service/src/index.ts @@ -1,12 +1,8 @@ -export { - MoneyAccountBalanceService, - serviceName as moneyAccountBalanceServiceName, -} from './money-account-balance-service'; +export { MoneyAccountBalanceService } from './money-account-balance-service'; export type { MoneyAccountBalanceServiceActions, MoneyAccountBalanceServiceEvents, MoneyAccountBalanceServiceMessenger, - MoneyAccountBalanceServiceConfig, } from './money-account-balance-service'; export type { MoneyAccountBalanceServiceGetMusdBalanceAction, @@ -18,5 +14,5 @@ export type { export type { ExchangeRateResponse, MusdEquivalentValueResponse, - VaultApyResponse, + NormalizedVaultApyResponse, } from './response.types'; 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 index 8108bc396b6..d56278468bf 100644 --- 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 @@ -10,10 +10,7 @@ import type { import nock, { cleanAll as nockCleanAll } from 'nock'; import { VedaResponseValidationError } from './errors'; -import type { - MoneyAccountBalanceServiceConfig, - MoneyAccountBalanceServiceMessenger, -} from './money-account-balance-service'; +import type { MoneyAccountBalanceServiceMessenger } from './money-account-balance-service'; import { MoneyAccountBalanceService, serviceName, @@ -144,16 +141,13 @@ function createServiceMessenger( * 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. + * @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({ - config = {}, options = {}, }: { - config?: Partial; options?: Partial< ConstructorParameters[0] >; @@ -192,7 +186,7 @@ function createService({ const service = new MoneyAccountBalanceService({ messenger, - config: { ...DEFAULT_CONFIG, ...config }, + ...DEFAULT_CONFIG, ...options, }); @@ -567,20 +561,6 @@ describe('MoneyAccountBalanceService', () => { 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}`) @@ -725,7 +705,7 @@ describe('MoneyAccountBalanceService', () => { .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); const { service } = createService({ - config: { vaultChainId: '0x1' as const }, + options: { vaultChainId: '0x1' as const }, }); const result = await service.getVaultApy(); 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 index 429f241df53..b23ab8ea2b0 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -30,9 +30,9 @@ import { normalizeVaultApyResponse } from './requestNormalization'; import type { ExchangeRateResponse, MusdEquivalentValueResponse, - VaultApyResponse, + NormalizedVaultApyResponse, } from './response.types'; -import { VaultApyResponseStruct } from './structs'; +import { VaultApyRawResponseStruct } from './structs'; // === GENERAL === @@ -108,24 +108,6 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< 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 === /** @@ -142,44 +124,64 @@ export type MoneyAccountBalanceServiceConfig = { * ```ts * const service = new MoneyAccountBalanceService({ * messenger: moneyAccountBalanceServiceMessenger, - * config: { - * vaultAddress: '0x...', - * vaultChainId: '0xa4b1', - * accountantAddress: '0x...', - * underlyingTokenAddress: '0x...', - * underlyingTokenDecimals: 6, - * }, + * 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 #config: MoneyAccountBalanceServiceConfig; - 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`, - * which is used to wrap each request. - * @param args.config - The configuration for the service. */ constructor({ messenger, + vaultAddress, + vaultChainId, + accountantAddress, + underlyingTokenAddress, + underlyingTokenDecimals, policyOptions = {}, - config, - }: { - messenger: MoneyAccountBalanceServiceMessenger; - config: MoneyAccountBalanceServiceConfig; - policyOptions?: CreateServicePolicyOptions; - }) { + }: MoneyAccountBalanceServiceOptions) { super({ name: serviceName, messenger, @@ -191,13 +193,14 @@ export class MoneyAccountBalanceService extends BaseDataService< }, }); - this.#config = { - ...config, - vedaApiBaseUrl: config.vedaApiBaseUrl ?? VEDA_PERFORMANCE_API_BASE_URL, - }; + this.#vaultAddress = vaultAddress; + this.#vaultChainId = vaultChainId; + this.#accountantAddress = accountantAddress; + this.#underlyingTokenAddress = underlyingTokenAddress; + this.#underlyingTokenDecimals = underlyingTokenDecimals; this.#networkName = - VEDA_API_NETWORK_NAMES[this.#config.vaultChainId] ?? + VEDA_API_NETWORK_NAMES[this.#vaultChainId] ?? DEFAULT_VEDA_API_NETWORK_NAME; this.messenger.registerMethodActionHandlers( @@ -207,7 +210,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Resolves a Web3Provider for {@link MoneyAccountBalanceServiceConfig.vaultChainId} by looking up the + * 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. @@ -217,12 +220,12 @@ export class MoneyAccountBalanceService extends BaseDataService< #getProvider(): Web3Provider { const config = this.messenger.call( 'NetworkController:getNetworkConfigurationByChainId', - this.#config.vaultChainId, + this.#vaultChainId, ); if (!config) { throw new Error( - `No network configuration found for chain ${this.#config.vaultChainId}`, + `No network configuration found for chain ${this.#vaultChainId}`, ); } @@ -235,9 +238,7 @@ export class MoneyAccountBalanceService extends BaseDataService< ); if (!networkClient?.provider) { - throw new Error( - `No provider found for chain ${this.#config.vaultChainId}`, - ); + throw new Error(`No provider found for chain ${this.#vaultChainId}`); } return new Web3Provider(networkClient.provider); @@ -271,7 +272,7 @@ export class MoneyAccountBalanceService extends BaseDataService< queryKey: [`${this.name}:getMusdBalance`, accountAddress], queryFn: async () => { const balance = await this.#fetchErc20Balance( - this.#config.underlyingTokenAddress, + this.#underlyingTokenAddress, accountAddress, ); return { balance }; @@ -292,7 +293,7 @@ export class MoneyAccountBalanceService extends BaseDataService< queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], queryFn: async () => { const balance = await this.#fetchErc20Balance( - this.#config.vaultAddress, + this.#vaultAddress, accountAddress, ); return { balance }; @@ -310,22 +311,22 @@ export class MoneyAccountBalanceService extends BaseDataService< * @param options.staleTime - The stale time for the query. Defaults to 30 seconds. * @returns The exchange rate as a raw uint256 string. */ - async getExchangeRate(options?: { - staleTime?: number; - }): Promise { + 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.#config.accountantAddress, + this.#accountantAddress, ACCOUNTANT_ABI, provider, ); const rate = await contract.getRate(); return { rate: rate.toString() }; }, - staleTime: options?.staleTime ?? inMilliseconds(30, Duration.Second), + staleTime, }); } @@ -353,7 +354,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const musdEquivalentValue = ( (balanceBigInt * rateBigInt) / - 10n ** BigInt(this.#config.underlyingTokenDecimals) + 10n ** BigInt(this.#underlyingTokenDecimals) ).toString(); return { @@ -368,13 +369,13 @@ export class MoneyAccountBalanceService extends BaseDataService< * * @returns The normalized vault APY response. */ - async getVaultApy(): Promise { + 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, + `/performance/${this.#networkName}/${this.#vaultAddress}`, + VEDA_PERFORMANCE_API_BASE_URL, ); const response = await fetch(url); @@ -389,7 +390,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const rawResponse = await response.json(); // Validate raw response inside queryFn to avoid poisoned cache. - if (!is(rawResponse, VaultApyResponseStruct)) { + if (!is(rawResponse, VaultApyRawResponseStruct)) { throw new VedaResponseValidationError( 'Malformed response received from Veda performance API', ); diff --git a/packages/money-account-balance-service/src/requestNormalization.ts b/packages/money-account-balance-service/src/requestNormalization.ts index 2f5ae2c816e..2a8238dfcad 100644 --- a/packages/money-account-balance-service/src/requestNormalization.ts +++ b/packages/money-account-balance-service/src/requestNormalization.ts @@ -1,7 +1,7 @@ import { Infer } from '@metamask/superstruct'; -import type { VaultApyResponse } from './response.types'; -import { VaultApyResponseStruct } from './structs'; +import type { NormalizedVaultApyResponse } from './response.types'; +import { VaultApyRawResponseStruct } from './structs'; /** * Normalizes the raw response from the Veda performance API into the expected @@ -11,8 +11,8 @@ import { VaultApyResponseStruct } from './structs'; * @returns The normalized response. */ export function normalizeVaultApyResponse( - rawResponse: Infer, -): VaultApyResponse { + rawResponse: Infer, +): NormalizedVaultApyResponse { const { Response: response } = rawResponse; return { diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts index b78a956c093..9f69321fbb9 100644 --- a/packages/money-account-balance-service/src/response.types.ts +++ b/packages/money-account-balance-service/src/response.types.ts @@ -24,7 +24,7 @@ export type MusdEquivalentValueResponse = { * 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 = { +export type NormalizedVaultApyResponse = { aggregationPeriod?: string; // E.g. "7 days" apy: number; chainAllocation?: { diff --git a/packages/money-account-balance-service/src/structs.ts b/packages/money-account-balance-service/src/structs.ts index a8bd4f0f082..c892959fe99 100644 --- a/packages/money-account-balance-service/src/structs.ts +++ b/packages/money-account-balance-service/src/structs.ts @@ -8,7 +8,7 @@ import { } from '@metamask/superstruct'; /** - * Superstruct schema for {@link VaultApyResponse}. + * Superstruct schema for {@link NormalizedVaultApyResponse}. * * Uses `type()` (loose validation) so that unknown fields returned by the * Veda API do not cause validation failures. @@ -16,7 +16,7 @@ import { * 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({ +export const VaultApyRawResponseStruct = type({ Response: type({ aggregation_period: optional(string()), apy: number(),